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>
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>
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>
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>
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 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>
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>
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>
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>
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>
- 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>
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>
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>
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>
Session f475e697: a device re-enrolled without a wipe (IME log residue from a
previous enrollment) completes its pre-installed apps as instant detections,
pulling the phase anchors ~6.5 min ahead of the real ESP page — the breakdown
was mechanically exact but semantically wrong, and entered fleet stats as clean.
- New duration-critical flag PriorEnrollmentResidue, set on
historic_ime_replay_detected; excluded from fleet segment stats via the
existing DurationCriticalFlags mask. registry_app_baseline successes are
deliberately NOT a trigger (by-design normal for WDP Batch-1) — pinned by test.
- AttributionVersion 1 -> 2 so the maintenance sweep recomputes in-retention
breakdowns and flags old residue sessions retroactively (rule 8).
- Web: flag label in TimeAttributionLane; docs: quality-flags section.
- Stale comment fix: SaveWhiteGloveSuccessResult is NOT a WhiteGlove indicator
on Win11 25H2+ (validated 2026-08-18 against user-driven sessions) —
observability only, consumed nowhere for classification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
versionBreakdown on the app analytics responses now carries measuredInstalls,
medianDurationSeconds and p95DurationSeconds per version (measured population:
succeeded, non-skip, plausible duration, no AppId collision). A new pure
AppVersionRegressionRadar detects "median install duration rose from X to Y min
after version Z": versions ordered by first-seen install time (never string
sort), fires at median lift >=2x AND >=300s absolute with >=10 measured
installs on both sides.
Episodes follow the rule-radar pattern in a fourth tracker keyspace
appversionregression|{app}|{version} (409-dedup, refresh keeps FirstNotifiedAt,
re-arm on drain / median back under 1.5x, 30d retention sweep re-arms): one
tenant bell app_version_duration_regression (Admin audience, appDetailUrl href)
plus one AppVersionDurationRegression ops event per episode, dual-registered in
the web OPS_EVENT_TYPES catalog. Runs in the maintenance timer after the rule
radar over one cross-tenant 35d install-summary read; kill switch
AppVersionRegressionRadarDisabled (fail-open). Apps detail page shows an amber
regression banner and a median-duration-by-version chart; OKF concept doc added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/annotations now carries the standard cross-tenant scope (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 never receive the platform-internal
globaladmin lane (endpoint-test-pinned).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/api/session-annotations sat outside every clientCertExclusionPaths
prefix, so browsers got the client-cert picker. Rename-over-exclusion
per the diag-files precedent (the Azure list is portal-only config and
dies on app recreation). Three segments (sessions/annotations/list) on
purpose: a two-segment literal would be eaten by sessions/{sessionId}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New GET /api/session-annotations (MemberRead + QueryParam, tenant always
the middleware-validated target) backing the portal /annotations page in
the Monitoring sidebar group (deliberately no new Insights group). For
callers without global scope the repository excludes the globaladmin
lane server-side (Lane ne clause), so hidden rows never consume page
budget and an explicit lane filter self-contradicts to empty. Rows
deep-link to the session annotation section. Verdict pill styling moved
to the shared logic module.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SubmitReportAsync adds annotations.json (all lanes) to the package and
annotationCount to report-metadata.json. All lanes are safe: report
blobs are readable only through the GA-only session-reports routes.
Fail-soft like the tenant-context snapshot; the copy outlives the
session=s retention/delete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}, lanes
operator/tenantadmin/globaladmin) storing structured verdicts + 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; the globaladmin lane is platform-internal and filtered
from tenant callers. Portal: SessionAnnotationsCard on session detail.
MCP: list_session_annotations (ga) + annotate_session (strictGa, GA lane
only) + annotations key in get_session_summary. Lifecycle: cascade steps
17-19, offboarding wipe, critical-backup member. Privacy page gains the
customer-entered-content line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First W365 field run: the rejected Cloud PC appeared in the report as an
anonymous "Microsoft Corporation / Virtual Machine" row — nothing told the
admin that the fix is the Cloud PC validator, not an Autopilot-registration
hunt. The agent's CloudPcDetector verdict (Windows365 key AND
CloudManagedDesktopExtension service) now rides the pre-auth distress channel:
- Agent: DistressReporter captures isCloudPc at construction (optional ctor
arg, test fakes stay source-compatible); BackendClientFactory feeds it from
CloudPcDetector; DistressReport.IsCloudPc on the wire (~18 bytes, fits the
1536-byte payload cap without a bump — comment updated)
- Backend: DistressReportEntry.IsCloudPc, table column + read mapping
(legacy rows -> false), structured log + AgentDistressReport AI event key;
GetDeviceNotRegisteredFunction aggregates sticky-true per serial (same OR
semantics as the Sessions IsCloudPc merge) and the data-quality notice
names the new self-reported field. GA global/distress-reports serializes
the full entry and picks it up automatically.
- Web: Cloud PC badge in the Devices Not Registered table (same sky badge as
the session table) + conditional hint pointing at the Windows 365 Cloud PC
Validation toggle and the W365CloudPcValidation Graph add-on.
- Tests: sticky-true + no-marker aggregation cases; MakeEntry helper gains
the optional flag. Docs: third transport rail in
backend/cloudpc-device-validation.md + log entry.
Unverified like every distress field — triage context only, never an auth
input. Verified: web tsc clean, eslint 0 errors (file's single warning
pre-exists). .NET build/tests run in CI (no SDK in this sandbox).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs
Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup
(and the Corporate Identifier fallback) structurally reject them and Cloud PC
first-connect enrollment could not be monitored. New stage-3 fallback in the
device-validation chain: extract the Intune device id from the chain-validated
MDM client certificate's Subject CN (field-verified: CN == managedDevice id ==
cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed
tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq).
Only service-provisioned Cloud PCs have such an object — a regular enrolled
non-Autopilot device still ends in the 403, and local W365 markers are never
consulted for auth.
- Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice
(default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll,
GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All
- Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract:
30/5 min cache, 2 attempts, transient->503; GUID gate before the OData
filter; exact-match guard; Graph 403 = definitive "grant missing", not
transient), SecurityValidator CN extraction + fallback stage + hard gate,
wiring through the 5 agent endpoints, TableConfigRepository persistence
- Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW
GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously
comment-only lock-step)
- Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the
Cloud-PC relax is active — Cloud PCs run headless for days before first
connect; the fresh-profile window is the time anchor there (+ Pester tests)
- Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label,
privacy-page validation claim generalized (Last updated bumped)
- Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log
Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet
cap, none added). .NET build/tests and Pester run in CI — SDK download and
pwsh are unavailable in this sandbox's network policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
* w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase"
Companion to the CloudPc validator: the agent now classifies Windows 365
Cloud PCs locally with the bootstrap's field-verified marker AND
(HKLM\SOFTWARE\Microsoft\Windows365 key + installed
CloudManagedDesktopExtension service, probed via its Services registry key
so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves
false. Never an auth input - backend admission stays exclusively the
cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are
independent and may disagree.
Two transport rails:
- Session metadata: SessionRegistration.IsCloudPc -> Sessions table
(sticky-true OR across re-registrations, IsSelfDeployingProfile pattern)
-> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search
filter end to end (SearchSessions + QueryRawSessions + pagination
fingerprint + all three in-memory predicates; MCP search_sessions /
query_raw_sessions params + summary projection; web type, Cloud PC pill,
SessionInfoCard enrollment label, CSV export appended column).
- Decision engine: isCloudPc rides the EnrollmentFactsObserved payload,
recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc
and annotates the scenario profile reason
cloud_pc_first_connect:no_device_esp_expected - the engine-recorded
expectation that Device-ESP already ran headless at provisioning and the
session starts at Account Setup. Deliberately reason+observation only:
no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-
classified (Classic), EspConfig keeps configured-FirstSync semantics.
Positive marker surfaces as cloud_pc_marker in the audit-trail census.
Updater idempotency: the cloudPcAnnotates gate keeps the generic v1
reason branch from re-claiming the slot on repeat posts (would bump
EvidenceOrdinal every post). Additive-nullable observation - no state
schema bump (v4 convention), serializer roundtrip pinned.
Tests: CloudPcDetector resolver (marker AND, throwing probe), registration
body, facts-signal payload shape, updater annotation/idempotency/
self-deploying interplay, reducer observation both values + census fold,
serializer roundtrip, index projection superset, sticky-true both
directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154,
vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99).
Docs: cloudpc-device-validation.md follow-up section replaced with the
shipped mechanism + log entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
First production auto-approve (mueller-steinag.ch) activated 64s after signup,
5s before the user saved their notification address on the activation page -
the approval path found no address and silently skipped the welcome mail.
Both racing paths now write-then-read: the notification-email save path checks
approval fresh (bypassing the 30s negative cache) and triggers the send when
activation already happened. A conditional-insert welcome-email-sent marker in
PreviewWhitelist makes the send once-only across both paths; it is consumed
strictly after the address check so it can never be burned without an address.
Revoke clears the marker, the GA resend endpoint sends unconditionally and
consumes it best-effort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.
- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
raw string; golden test pins byte parity with the TS template
(TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
/api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
section, index/log entries.
Backend tests 3611/3611, web vitest 651/651, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Analyze rules only (gather stats stay per-batch-deduped until Q6 is
fixed - never silently included). Per (tenant, rule) the radar compares
the trailing 7-day hit rate (FireCount/SessionsEvaluated from the daily
RuleStats rows) against the prior 28-day baseline and fires only when
the window has >=5 hit sessions and >=20 evaluated sessions, the rate
is >=2x baseline AND the Wilson 95% intervals are disjoint
(MetricsMath.WilsonInterval/RateIncreaseSeparated, hand-computed test
vectors). Suppressed for deleted rules, rules inside the CreatedAt
grace period (<35d), rules edited inside the window, empty baselines,
and the RuleRegressionRadarDisabled app-setting kill switch.
Alert episodes live as ruleregression|{ruleId} rows in the notification
tracker (third keyspace): AddEntity-409 dedup = one bell + one
RuleFrequencyRegression ops event (category Tenant, Warning, full
numbers, dual-registered in the web OPS_EVENT_TYPES) per episode;
numbers refresh while active without moving FirstNotifiedAt; episodes
re-arm under 1.5x baseline, on fire-stop, or via the tracker's 30d
retention prune (spec-intended reminder). On-fire dimension correlation
(osBuild/model/agentVersion/imeVersion; hit set from a RuleResults
range scan intersected with the window sessions) reports both shares
with the "correlated, not necessarily causal" wording contract or
honestly "no clear dimension concentration". Radar runs in the daily
maintenance pass after the rule-stats aggregation, anchored on
yesterday (whole days only).
Surfaces: regressions[] in the rule-stats response (MCP get_rule_stats
inherits it), tenant bell deep-linking to the rule card, and the
analyze-rules page gains a densified 30-day fire sparkline plus a
regression badge while an episode is active. OKF doc
backend/rule-regression-radar.md + index/log.
Backend 3474/3474 (27 new: Wilson vectors, every suppression branch,
window boundary dates, re-arm matrix, dimension gates, tracker
round-trips incl. tri-states, keyspace dedup/prune, wording pins),
web tsc + vitest 627/627, MCP tsc + 455 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three read routes over the PR4 data: metrics/device-history
(MemberRead + tenant query param - one route serves the web banner AND
MCP, GA cross-tenant via ?tenantId= with a sessionId-based owner
fallback; history:null is a normal outcome), metrics/device-journeys
and global/metrics/device-journeys (days selector honored because FTR
counts are additive - window rate = ratio of summed daily rows; the
tenant-less global variant serves the "global" partition and discloses
repeatDevices:null). Attempt numbers are server-computed
(DeviceJourneyCalculator.ComputeAttemptNumber: chain position within
the journey; live sessions via a virtual non-successful attempt so the
redeploy and 30d-gap rules apply) - no consumer re-derives journey
semantics. Repeat-devices violator list: last journey >=2 attempts with
newest terminal ref in-window, attempts desc then recency, cap 10,
failure reason from the newest failed attempt (bounded point-reads).
Web: DeviceHistoryBanner on session detail ("Attempt N for this device
- View history", expandable terminal chain, durations strictly the
authoritative durationSeconds, WG/DevPrep/admin-marked badges) and the
Fleet Health "First-time-right" section (rate gated at >=20 completed
journeys with disclosed n, junk-serial exclusion note, Monday-UTC
weekly trend, attempt histogram, repeat devices) following the 7/30/90
selector. MCP: get_device_history (serial mode / fleet FTR mode,
role-aware routing, all roles - GA_FULL privilege snapshot extended
deliberately). Docs: OKF backend/device-journeys.md + index/log.
Backend 3447/3447 (14 new: attempt-number vectors incl. gap boundaries
and live sessions, additive sums, days clamping, repeat-device
gate/sort/cap), web tsc + vitest 627/627, MCP tsc + 455 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Insights expansion PR3 (tasks/insights-expansion-spec.md §F1 "Surfaces").
Completes F1: schema (PR1) -> persistence/aggregation (PR2) -> surfaces.
- Rolling range rows: the sweep now also writes "rolling30|{class}"
aggregate rows — range statistics over the window's actual sessions. A
median of per-day medians is not the range median, so the fleet panel
reads these and is deliberately fixed to "last 30 days"; the daily rows
stay in the payload for trends/MCP.
- Routes (all registered in EndpointAccessPolicyCatalog):
GET sessions/{sessionId}/time-attribution (MemberRead + tenant query
param, GA fallback via FindSessionTenantId; breakdown=null is SUCCESS —
a missing lane is a normal state, not an error), GET
metrics/time-attribution (MemberRead) and GET
global/metrics/time-attribution (GlobalReadOrAdmin + tenantId filter),
both returning { windowDays, classes (rolling), daily }.
- Web: TimeAttributionLane under the session-detail phase timeline —
proportional stacked bar of segment totals incl. the explicit
unattributed remainder (bar always sums to the session's authoritative
duration), ESP-blocking apps with critical-path occupancy, reboot badge
(cross-cutting, never a bar slice), quality-flag chips; fetched
fail-soft for terminal sessions only. Fleet Health gains the "Time
attribution" section: per-class stacked medians gated at n>=20
("insufficient data (n=...)" below), flagged/missing exclusions
disclosed, and a top-blocking-apps table with class-scoped rows and
"up to X (p75 Y)" what-if wording. Deliberate deviation: the spec's
per-app trend column is omitted — per-day app rows are too sparse under
the daily n>=5 gate; revisit once rolling history exists.
- MCP: get_time_attribution (one tool, session + fleet mode, role-aware
routing like get_app_install_metrics, registered for all roles) — the
privilege-leak-guard catalog snapshot extended deliberately.
- Docs: OKF concept doc backend/time-attribution.md (invariant, windows,
segment mapping, positive-evidence blocking, Q7 reboot spans,
persistence + surfaces) registered in index.md + log.md.
Backend 3382/3382 (+1 rolling-aggregate test), web tsc clean + vitest
627/627, MCP tsc clean + 455 passed. Post-deploy: web deploy +
MCP redeploy (new tool + docs corpus).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>