# Changelog All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [2.6.1] — 2026-10-30 Nothing in the app changed. What changed is what the app looks like when someone shares a link to it, and what the README claims about how it reads a tenant. ### Added - **Social cards on the two URLs people actually paste.** `/app/` and `/demo/` carried a description and nothing else — no `og:title`, no `og:image`, no `twitter:card` — so shared into LinkedIn or Teams they unfurled as a bare link, which for a tool whose appeal is a picture is the worst of the available first impressions. Both now carry a per-page title and description, a canonical URL and a 1200×630 card. The tags are injected from `vite.config.ts` keyed off the base path the bundle was built for, which is what lets a self-hosted build opt out: it is served from someone else's network, where an absolute `og:url` naming this GitHub Pages site would be a lie. A browser build made for some other base gets nothing either, for the same reason. The card itself is regenerated by hand from the hero screenshot via `scripts/make-og-card.py`, deliberately outside the build — threading Python through a Node pipeline to redraw one image twice a year is not a trade worth making. ### Fixed - **The README no longer presents app-only as the only way the tool reads a tenant.** Three passages predated browser mode, so "Why preflight?" contradicted the comparison table eight lines above it: the table says browser mode sees the tenant as scoped to your Intune RBAC, the paragraph said the tool is not filtered by scope tags at all. Both claims are now scoped to self-hosted mode, with what browser mode does instead said in the same breath, and the third introduction of browser mode — which repeated what the intro and the table had already covered — is gone. ## [2.6.0] — 2026-10-29 The screen belongs to the data again. Status moved into the toolbar it was describing, the picker gave its wasted column back to the groups list, and an export can now cover what you are actually looking at. ### Added - **Export the view, or the whole baseline.** Reading one configuration profile and clicking Export handed you the entire tenant's baseline. The menu now offers both, with the count of each, and defaults to what is on screen — someone who narrowed the view and then reached for Export was most likely asking for what they narrowed it to. The choice only appears when the two differ. Scope, view and search are all applied by one function the panel and all three builds share, so a filtered export is the same rows wherever it is produced, through the same serialiser as a full one. ### Changed - **Status lives in the toolbar instead of above it.** The authentication banner is a lock that spins while the tenant loads, goes red if it failed, and opens on hover or a click for the signed-in account, the tenant, the Graph request count and End session — the identity still concealed until asked for. The compact-layout notice is one glyph, since what mattered in it was the switch. The tabs stop taking a row of their own on a narrow window. Measured on a phone: the diagram goes from 189px of an 812px window to 633px — 23% of the screen to 78%. - **Device filters are a dropdown, and the groups list has the width.** Filters were a third column that was usually empty, while the groups list — long names, the control people actually work in — was narrowed to pay for it. The closed dropdown names what is selected rather than only counting it: an exclude filter removes policies from the simulation outright, so a filter that is on while its control is shut has to stay legible. - **The groups note moved above the list.** "All Devices & All Users always apply" sat under a long scrolling list, which is where it was least likely to be read. - **Demo mode derives its CSP areas** through the same code a connected tenant runs, instead of carrying areas written into the sample data by hand. ### Fixed - **Tapping a text field on iOS shifted the app up and left a blank strip behind.** Known since 2.3.0 and disclosed there as a document that could still scroll. It was not the document: it was Safari's own zoom. Safari zooms in on any form control it focuses whose font size is under 16px, and on blur it neither zooms back out nor undoes the scroll it did to lift the control above the keyboard — so the group search box, 12px like every other control, zoomed the page to 1.33× and scrolled it 282px, and that is where it stayed. Form controls now have a 16px floor on a touch pointer, and `` and `` say outright that the document does not scroll. ## [2.5.1] — 2026-10-28 The merged baseline's CSP areas say what they are. They are sorted, they carry their real capitalisation, a mislabelled bucket of Microsoft Edge settings is now called Microsoft Edge, and templates an admin uploaded are marked as such. ### Fixed - **34 Microsoft Edge settings were filed under "Microsoft".** An ADMX setting carries its namespace before a literal `~policy~`, and that namespace can contain underscores — `microsoft_edge`. The area was read by splitting on `_` and taking the first segment, which stopped at `microsoft`: a vendor rather than a product, and a bucket any other `microsoft_*` namespace would have shared. Searching the baseline for what touches Edge would not have found it under E. - **One product was scattered across five areas.** The same rule left OneDrive's namespace intact because it has no underscore, so `v2`, `v2.updates`, `v3`, `v4` and `v6` each became an area of their own — while thirteen Edge namespaces were being crushed into one. Vendors ship a new ADMX namespace per release rather than versioning in place, so the version suffix is now dropped and a product is one area. - **Settings whose category Graph leaves empty are no longer dumped in a generic bucket.** Graph returns a null `categoryPath` on some uploaded-ADMX definitions — in one tenant, three of the six settings in a single Chrome policy, while the other three carried a full path. Those three sat under "Administrative Templates", saying nothing about what they configure. The category is resolved by id instead and reads "Google Chrome". If it cannot be read, the load records a warning and the old label is kept: a missing category is a worse name, not a missing setting. ### Changed - **CSP areas are sorted A–Z.** They came out in the order the policies happened to be merged, which is an implementation detail rather than something a reader can use. Settings within an area keep their policy's own order, and the sort runs after the merge, so no finding changes. - **Areas carry their real capitalisation.** `Applicationmanagement` reads "Application Management", `localusersandgroups` "Local Users And Groups", `sharedpc` "Shared PC", `onedrivengsc` "OneDriveNGSC". The name comes from the setting's own CSP path, which spells the area as Microsoft does — not from a list of friendlier names this project chose, which could claim an area is something it isn't. Where the path gives nothing clean, the old name is kept rather than guessed at. - **Templates uploaded to the tenant are marked.** Third-party ADMX now reads "ADMX (Imported) Mozilla\\Firefox". An imported ADMX is a file somebody chose to add, so a setting under it that looks wrong may be fixed by re-uploading or removing the template — which is not true of anything Intune ships. Built-in templates are unprefixed. ## [2.5.0] — 2026-10-28 The diagram is readable on a phone. A policy type is a chip you tap to get its policy names, and the view zooms to the policies instead of trying to fit the whole graph into a screen that cannot hold it. ### Added - **A policy type opens its policies in a pop-out, below `lg`.** Tapping a type on the diagram brings up its policies with their names wrapping in full — these run to sixty characters and the part that distinguishes one from another is almost always at the end, so a truncated name is not a name. Tapping a policy opens the merged baseline scoped to it, as before. The pop-out reads the layout's own data, so it and the diagram cannot disagree about which policies are of a type or how many settings they carry. ### Changed - **A policy type is a chip below `lg`, not a 400px list.** The type bubble lists up to twelve policy names, which is right on a desktop canvas and unusable on a phone: the node was wider than the viewport and, for a Settings Catalog of thirteen, 421px tall. The chip carries what survives being small — colour, label, policy count, settings total — and the names moved to the pop-out. Unchanged at `lg` and up. - **The narrow-window diagram aims at the policies rather than fitting the whole graph.** Fitting three columns totalling 1050px into a 375px canvas landed at 27% zoom, rendering the nodes' 11px type at 3px. The view now goes to the policy column at up to full size, with the device and its groups off to the left where they have always been — the diagram still reads left to right, and panning left walks back along the flow. Measured on a phone-sized window: 12.6px against 2.6px. - **The merged-baseline rail is desktop-only.** Below `lg` it was a collapsed bar that opened a half-screen accordion of the baseline by CSP area, over the diagram — a fourth list on a phone, arriving from the bottom of the screen rather than from anything tapped, and grouped by an axis the diagram does not use. Reaching the baseline is now one path: a type, then a policy, then its settings, with a button on the canvas for the whole thing. The rail is untouched at `lg` and up. - **The `.xlsx` export is described by what it is for.** The hint read "iPad included", which was the story of how the limitation was found rather than what it means when choosing a format; mobile Excel has the same constraint on any device. ### Fixed - **Platform Scripts counted their own payload as settings.** A script's settings are `scriptContent`, `runAsAccount`, `enforceSignatureCheck` and the rest, read off the policy itself — the engine namespaces them per policy precisely so they never merge with anything, because scripts do not conflict the way a shared CSP setting does. Reporting "1 policy, 4 settings" counted a script's run options as though the device resolved them, reading as four times more policy than is there. A type of scripts now reports how many scripts, and each one reports "script config". The merged baseline's own total is unchanged. - **The diagram stayed zoomed out after a panel was collapsed.** Collapsing the endpoint picker or the Policy Waitlist hands the diagram a few hundred more pixels, and it kept the zoom it was given while it was short — sitting small with empty space beneath it. It now re-aims whenever the canvas changes size. ## [2.4.0] — 2026-10-28 Exports can now be an Excel workbook, which is what finally opens on an iPad — and the formats moved behind one menu rather than a button each. ### Added - **Excel (`.xlsx`) export, alongside CSV and JSON.** Excel for iPadOS refuses the CSV exports as corrupted however correct the file is — mobile Excel has no Text Import wizard, so it declines a format whose delimiter, encoding and column types have to be inferred, and Microsoft's own compatibility table lists `.csv` on iOS as read-only at best. An `.xlsx` declares all three in a schema. Because the cells carry their type, values like `=SUM(A1)` and `2026-10-28` arrive as the text they are, without the quote prefix a CSV needs to stay safe from formula injection. Written in the engine with no new dependency, so the self-hosted, demo and live builds all produce the same bytes. ### Changed - **One "Export ▾" menu in place of a button per format.** The merged baseline offers Excel, CSV and JSON; the Assignment Manifest offers Excel and CSV. Three buttons crowded a toolbar that already wraps at phone width. ## [2.3.3] — 2026-10-28 Exports download the same way everywhere, including on iPhone and iPad, where they now land in Files › Downloads. ### Changed - **Exports no longer use the iOS share sheet.** A Blob on a download link turns out to work on iOS and iPadOS perfectly well — Safari shows its own download prompt and the file goes to **Files › Downloads** — which makes the share sheet unnecessary, and worse than the thing it replaced: it asked where to put the file, so there was no predictable place to look for it afterwards. The original fault in 2.3.0 was specific to `data:` URLs, which Safari refuses to save; moving to a Blob was the whole fix, and routing iOS through `navigator.share` was a second change on top of a solved problem. That branch then leaked onto Windows, where sharing files is also supported, which is what 2.3.2 had to patch. There is now one code path for every build and every platform, and no platform detection left to get wrong. ### Note - Excel for iPadOS still refuses to open the CSV exports. The file is correct — it carries a UTF-8 BOM and CRLF endings, and opens in Excel on both macOS and Windows — so this is a limitation of Excel on that platform rather than a problem with the export. A native `.xlsx` export is the fix and is not in this release. ## [2.3.2] — 2026-10-28 A one-fix release. Exports download again on Windows. ### Fixed - **On Windows, the CSV export opened the share dialog instead of downloading**, while the JSON export beside it downloaded normally. The code asked `navigator.canShare({ files })` — whether sharing a file is *possible* — and treated the answer as meaning the device could not download. Those coincide on iOS and diverge everywhere else: Windows has a native share sheet and Chrome and Edge expose it, so a desktop user clicking **Download CSV** got a share dialog. The split between the two formats is what made the cause legible — Chrome permits only certain file types to be shared, `text/csv` is on that list and `application/json` is not, so one export shared and the other downloaded on the same machine. The share sheet is now used on iOS and iPadOS only, which is the condition it was always meant to detect; everywhere else, including Windows machines with a touchscreen, gets an ordinary download. Introduced in 2.3.0. ## [2.3.1] — 2026-10-28 Follow-up to the same day's 2.3.0. Exports can be opened in Excel again, the Policy Waitlist works at phone width, and the browser build no longer prints who is signed in. ### Fixed - **CSV exports could not be opened in Excel**, which reported them as corrupted while Quick Look displayed the same file perfectly. Nothing was wrong with the content — it was valid CSV and invalid Excel input. Two things were missing around it: a UTF-8 BOM, without which Excel guesses the encoding and the iPadOS version declines the file rather than guessing wrong visibly; and CRLF between records, which is what RFC 4180 specifies and which desktop Excel forgives but mobile Excel does not. Both now live in the engine, so the self-hosted, demo and browser builds cannot emit different bytes for the same export. - **The Policy Waitlist was still built for a mouse.** Its expanded list was capped at 160px — about four rows on a phone — with tap targets under 30px, on a list whose entire purpose is ticking things. The cap is now 45vh below the breakpoint, the rows match the endpoint picker's, and the toolbar wraps so the search field gets its own row. Its summary line also says *which* policies are on the waitlist once any are, rather than only how many; the bar is collapsed by default, so that line is the only place the selection shows. ### Changed - **The browser build no longer prints who is signed in.** The session bar used to read "Signed in as «name» («upn») · tenant «guid»", and it sits at the top of the window — so it appeared in every screenshot, demo and screen-share of that build. It now reads **Authenticated to tenant** behind a lock that opens to reveal the account and tenant id. Neither was ever a secret and both are one click away; what changes is that publishing them is a decision rather than the default. Ending the session, the read-only note and the Graph progress counter are unchanged. ## [2.3.0] — 2026-10-28 Intune Preflight now works on a phone, and the merged baseline sits beside the diagram rather than on top of it. Two fixes to how disagreements between policies are reported, one of which was telling admins a device was stuck when it wasn't. ### Added - **The app works on a small screen.** Opening the site on a phone previously gave you a full-screen "Built for a bigger screen" wall and nothing else — and underneath it the picker's three fixed-width columns overlapped each other at 375px and the canvas was left about 180px. There is one breakpoint, 1023px, deliberately Tailwind's `lg` minus one so a component's classes and the JavaScript that branches on width cannot drift apart. The wall is now a thin banner with a **Use desktop layout** escape that lays the full console out at 1280px so an iPad can pan and pinch-zoom the diagram. - **The merged baseline is on screen with the diagram.** A 24rem rail at `lg` and up, a collapsible sheet below it. The baseline is the answer this tool exists to produce and reading it used to mean covering the thing that produced it — open the drawer, read it, close it, change a group, open it again. Tick a group now and the graph and the conflict count move together. The full panel is still one click away for the five-column grid, the exports and the column picker. - **Focus mode**, on the ⛶ button: every picker and the app header hidden, the diagram given the whole window. It keeps exactly one control, the Group Tag, because it is the only one whose effect *is* the diagram — type a tag and the groups scoped to it are re-selected while you watch. - **Both data tables have a card renderer below `lg`.** The merged baseline becomes an accordion of CSP areas carrying the counts and the conflict/overlap/legacy badges on the headers; the assignment manifest becomes one card per group with Direct/Inherited/Incl-Excl as a three-up strip, and sorting moves to a dropdown since there are no column headers left to click. Both read the same rows as the desktop renderers, so the two presentations cannot disagree about what is on screen. ### Fixed - **Exports never saved on iPadOS.** The prompt appeared, you accepted it, and no file arrived. The browser-only builds have no backend to set a `Content-Disposition`, so both built the file as a `data:` URL on an anchor — and Safari does not honour `download` on a `data:` URL, so the click stopped being a download and became a navigation to a `data:` URL, which Safari blocks outright. Exports are now written from a Blob, and offered to the share sheet where the browser supports sharing files, because "Save to Files" is the path that actually lands a file on iPadOS. The self-hosted build was never affected. - **A conflict card named a winner it could not know.** The compact card ranked its values WINS/ALSO, the first in emerald as "the one that applies". It was reading the order of the contributing policies, which is tenant load order and not an ordering at all — the merge has always documented that it "deliberately doesn't claim to know which value wins on the device". Every value now renders identically, with no ranking. ### Changed - **A disagreement that Microsoft documents a resolution rule for is now reported as *layered*, not as a conflict.** A conflict in this tool means what Intune means by it: the device applies *neither* value until an admin resolves it. That label was being given to every disagreement whatever the policy type, and for several types it is a false alarm in the dangerous direction. Three Windows Feature Update profiles naming different versions were reported as a conflict, when Windows Update evaluates every applicable policy and offers the latest applicable version — nothing is stuck. The same applies to compliance policies (most restrictive applies), driver update profiles (approved wins) and quality update profiles (no rule documented at all). Update rings are deliberately unchanged: they genuinely *are* a conflict, with neither value set until it is resolved. A layered finding **states** the rule and never applies it — no winner is named, in the UI or in the JSON and API output. ## [2.2.1] — 2026-10-21 Follow-up to the same day's 2.2.0. The picker now lets you read any dynamic group's membership rule, the site deploys its two app builds together so they cannot drift apart, and the docs stop describing the tool as self-hosted. ### Added - **Any dynamic group's membership rule can be read**, through a collapsed disclosure under the group name. It was previously shown only when the rule could not be fully decided, and every one of those triggers needs a Group Tag typed in or the Autopilot box ticked — so an untouched picker showed no rules at all. The demo looked more informative than a connected tenant only because its sample data contains more undecidable rules. Rules we cannot decide still open themselves, because there the rule is a warning rather than a detail. ### Fixed - **A long membership rule was truncated at the panel edge**, which hid the `and` clauses on exactly the rules worth reading. It wraps now. - **Two stale documentation anchors**, one of which predated this release. ### Changed - **`/demo` and `/app` now deploy from the same tag, in one commit.** `/demo` has redeployed itself since v2.0.3; `/app` shipped by hand in 2.2.0 with no workflow, so the next tag would have refreshed one and left the other behind — two URLs on one site, the same application, quietly on different code. The workflow also refuses to publish a browser build compiled for the wrong base path, which would otherwise break every visitor's sign-in with `AADSTS50011` while looking perfectly healthy in CI. - **The README and setup guide lead with what the browser build removes**, rather than opening on a choice between two ways to run it. Self-hosting is framed as the deliberate option it is: the only mode that sees a tenant unfiltered, and the answer when Conditional Access won't issue a token to a single-page app. ## [2.2.0] — 2026-10-21 Intune Preflight can now run **entirely in a browser tab**. An admin opens a URL, signs in with their own account, and the page reads their tenant straight from Microsoft Graph — no server, nothing installed, and **no client secret anywhere**, because a single-page app is a public client and PKCE replaces the secret. The tenant data lives in the tab and goes when it closes. **Self-hosting is unchanged and stays fully supported.** The Fastify server, the `.env` contract, the Docker images and app-only authentication are byte-identical to v2.1. They remain the only way to get the complete, *unfiltered* view of a tenant, because a delegated token is scoped to the signed-in admin's Intune RBAC — which is narrower for a scoped admin. Browser mode is the easy way in; self-hosted is the thorough one. Nothing in the engine changed. This is a third way to *load* a tenant, not a new way to reason about one — which is why it is 2.2 and not 3.0. The v3 bets on the roadmap (combined dynamic-group rule evaluation, richer user modelling) are untouched. ### Added - **Browser (delegated-auth) data source.** A `build:live` bundle that signs in with MSAL, reads the tenant client-side through the same engine the server and the public demo use, and holds nothing on disk. Tenant and client ID persist in `localStorage` (neither is a secret); the token lives in `sessionStorage`, so closing the tab ends the session. A `?tenant=…&client=…` link self-configures the page, which suits consultants who carry one bookmark per client tenant. - **`packages/graph`** — the Graph-reading layer, extracted from `apps/server` so it can run in either host. Its only injected dependency is `getToken: () => Promise`: the server supplies an app-only MSAL-node token, the browser a delegated one. A `purity.test.ts` keeps it free of `node:*`, `process.env` and any reach back into `apps/server`. - **First-run setup panel** in the browser build, with the exact redirect URI the build will send — the value Entra compares character for character. ### Fixed - **Both purity tests could fail open**, which is the worst way for a guard to fail: it reports green forever while the boundary it protects rots. Type-only statements were stripped with an unbounded `[\s\S]*?`, which assumes every `import type` / `export type` carries a `from` clause. A local alias such as `export type TenantMode = "demo" | "connected";` does not, so the scan ran on to the next unrelated `from "…"` and deleted everything between — blinding the import check over the rest of the file. In `packages/engine` it had been quietly skipping 481 characters of `baseline.ts`. Statements are now bounded to themselves, side-effect imports (`import "x";`) are caught for the first time, and both extractors have tests of their own. - **The header's Demo button did nothing in an unconfigured browser build.** The setup panel owned the dismissal flag, and `switchMode` returned early because an unconfigured build already reports `demo === true` — so of two controls offering the sample tenant, only the panel's own link worked. ### Changed - The test suite went from 178 to 245. ## [2.1.0] — 2026-09-23 v2.1 is the release that makes v2 something you can leave alone. No new capabilities — v2's feature set is complete and meant to stay that way (the roadmap now separates remaining **v2 maintenance** from the **v3 bets**). What's here is the structural, correctness and reliability work behind that promise, plus the bugs a deliberate quality pass turned up: a browser setting that could blank the page, a Graph response that could hang a tenant load, a filter that under-reported, and a request size that a large tenant could genuinely hit. The test suite went from 114 to 178, and now covers the three things that had none: the whole documented API surface, the Graph retry loop, and the simulator's state rules. ### Fixed - **The "Legacy policies" baseline filter missed settings a legacy policy only co-configures.** The merged baseline carries one row per setting, and the view keyed off that row's single `sourceKind` — whichever contributing policy the merge happened to reach first. So a setting configured by *both* a Settings Catalog policy and a legacy Endpoint Security intent appeared under "Legacy policies only" only if the legacy one sorted first, and the count in the dropdown was wrong the same way. Both the filter and the count now key on **every** contributing policy — exactly the settings a migration needs to look at — and are derived from the same data, so they can no longer disagree. - **A conflicted setting no longer reads as an effective value over the API.** For a setting two applied policies disagree on, the single merged `value` is whichever policy was reached first — not a computed winner. The UI and the CSV export already expanded conflicts to a row per value, but a consumer of `GET /api/simulate` or the JSON export saw one confident-looking value. Such a row now carries **`conflicted: true`**, pointing at the full disagreement in `conflicts`; it follows the same Windows-only gate as conflict detection, so the flag never points at a deliberately empty list. Documented in [docs/SETUP.md](docs/SETUP.md#reading-a-conflicted-setting-from-the-api). Deciding a winner stays deliberately out of scope — see "Considered and parked" in [ROADMAP.md](ROADMAP.md). - **The merged-baseline panel is keyboard-usable.** It was a panel you could only leave with the mouse: no **Esc**, no focus management, nothing announcing it to a screen reader. Esc now backs out one level (a drill-down scope first, then the panel — matching the group policy list), focus moves into the panel on open and returns to whatever opened it on close, and it's labelled as a dialog. Not `aria-modal`, because it's a docked side panel and the diagram beside it stays usable. - **`docker compose up` no longer 502s on the first page load.** `web` waited only for the server *container*, not for the API to answer; the server now has a healthcheck and `web` waits for it. - **A browser with site data blocked no longer blanks the page.** Safari's "Block all cookies", Chrome's blocked-site-data setting and some enterprise policies make *every* `localStorage` access throw — reads included. The merged-baseline panel persists its width in a render effect with an unguarded write, so opening it threw, and with no error boundary React unmounted the whole app. Reproduced on the built demo (clicking "View merged baseline" left the page empty) and fixed: storage access is now guarded everywhere, and an **error boundary** catches anything else — showing the error, a Reload button and a link to report it, instead of a white screen. - **A stalled Graph response body could hang a tenant load indefinitely.** `fetch` resolves when the headers arrive, and the per-request timeout was being cleared before the body was read — so a response that stalled mid-body had no timeout at all, pinning one of the eight concurrent workers. The body read now happens inside the timeout's scope and retries like any other dropped connection. Also fixes the load telemetry undercounting by one: the final failing attempt of a throttled request wasn't counted, and `graphGetCollection` could throw a `TypeError` on a 204 instead of returning an empty result. - **Searching in the Assignment Manifest relabelled a checked group with its GUID.** The seating charts read group names from the search-filtered list while the selection bar shows every checked group, so filtering one out replaced its chart label (and its drill-down title) with the raw group id. - **Very large selections no longer fail with a bare HTTP 431.** Group ids travel in the query string, so ~430 selected groups passed Node's 16KB header limit and the request was rejected before it reached the app. The limit is raised to 64KB — verified past 1,500 groups. ### Changed - **The simulation engine is its own package (`packages/engine`).** It was living in `apps/server` next to the Graph client, and the web app reached into it sideways through a Vite alias and a file-wide `@ts-nocheck`. The engine is pure by design — that's what lets the identical code run behind the API *and* fully in the browser for the public demo — but nothing enforced it: `TenantData`, the engine's own input type, was declared inside the module that fetches it, and purity held only because every reference happened to be an erased `import type`. One value import would have broken the demo build with no warning. Now the boundary is a package dependency the compiler checks, and the guard is explicit three ways: a purity test that names the offending file, an ESLint rule while you type, and CI building the demo so the browser path is proven end to end. The alias, the custom Vite resolver plugin and the `@ts-nocheck` are all gone, so the in-browser API implementation is type-checked against the server's surface — drift between them is now a build error. - **The simulator's state is one tested reducer** (`apps/web/src/endpointDevice.ts`). Six `useState`s and a chain of `useEffect`s reacting to each other became a pure function of state + action, which separates rules that must always hold (Autopilot is Windows-only) from effects belonging to one action (switching OS clears device filters). Both bugs that cascade shipped — StrictMode's double-invoked mount effect wiping filters seeded by a Manifest handoff, and the Autopilot rule silently stripping deliberately-selected groups — are now pinned by regression tests, and this state is unit-testable for the first time. - **`npm run lint` actually lints.** It was `tsc --noEmit` alone; two `eslint-disable` comments were sitting in the app suppressing a rule that was never installed. ESLint is now configured (with `react-hooks/exhaustive-deps` as an **error**, since a stale dependency array is precisely what cost the simulator those two fixes) and Prettier formats the code — docs stay hand-wrapped and out of the formatter's reach. CI runs `lint`, `format:check`, `build`, `test` and the demo build. - **Docker images are reproducible and slimmer.** Both Dockerfiles used `npm install` despite a committed lockfile, and shipped the whole build-time `node_modules` — TypeScript, tsx and all — into the runtime image. Now `npm ci`, a separate `--omit=dev` dependency stage, a non-root user, and Node **20** to match `.nvmrc` and CI (they said 22). - Eight copies of the same copy-mutate-return Set-toggle closure became one helper (`apps/web/src/collections.ts`), and the shared policy-heat scale moved out of the component that draws it (`apps/web/src/policyHeat.ts`). - The Manifest's per-group seating charts index their rows once instead of rescanning every assignment row per checked group on every render — this view re-renders on each keystroke in its own search box. The group picker had the same shape (classifying membership rules inside a sort comparator, so O(n log n) re-parses per keystroke) and now ranks each group once: 4.1ms → 0.6ms at 800 groups. - **The beta endpoint fallback only fires on 400/404 now.** It exists for the v1.0 "Resource not found for the segment" response, but caught every failure — so a 403 for a missing permission, or an outage, was quietly attempted twice and reported against the wrong URL. - **`SimulationDiagram` is four files instead of one 972-line one**: `diagram/theme.ts` (the visual and layout vocabulary), `diagram/nodes.tsx` (the node components), `diagram/buildGraph.ts` (the layout engine) and the view itself. No behaviour change — verified against the built demo. The legend now derives its colours from the theme rather than repeating nine hex codes that could drift from the nodes they label. ### Added - **The public demo deploys itself.** Publishing it to `gh-pages` was a manual step that followed from no source push, which is exactly why the live demo could silently lag a release — and it's both the project's front door and where CONTRIBUTING asks people to reproduce bugs. A workflow now builds and publishes it on a release tag (or on demand), touching only the `demo/` subtree so the landing page and screenshots are left alone. - A favicon and a meta description on the app shell (inline SVG, so nothing 404s when the app is served from a sub-path like the demo's). - **Tests for the whole documented API surface** — all ten endpoints in docs/SETUP.md, driven through Fastify's `inject()` with no socket and no Graph, against demo mode. They had none before, which is how the 431 ceiling above went unnoticed. The server is now `buildServer()` plus a seventeen-line entry point, so the routes can be imported without opening a port. - An **invariants test suite** for the engine: properties checked over 400 seeded-random endpoints (one baseline row per setting, `conflicted` always having a conflict to point at, nothing both applied and excluded, the CSV staying rectangular, determinism, monotonicity) rather than more examples. - **Tests for the Graph retry loop** — the code that runs against a real tenant under throttling previously had none: 503-then-success, a 403 that must not retry, giving up after five attempts, `Retry-After` beating the backoff, a dropped connection, a body failing mid-stream, `@odata.nextLink` paging, 204. ### Docs - **ROADMAP split into [v2 maintenance](ROADMAP.md#v2-maintenance) and [v3 bets](ROADMAP.md#v3-bets).** The forward-looking list read as six commitments led by the largest of them (a real dynamic-group rule parser). v2 maintenance is now explicitly the whole scope of the 2.x line, the rule parser is explicitly deferred — the "surfaced, not guessed" Group Tag work is what makes it an accuracy upgrade rather than a correctness prerequisite — and decisions already taken (effective-value resolution, group-collision analysis) are recorded as parked with their reasoning. - CONTRIBUTING and SETUP document the engine boundary, the reducer pattern for UI state with rules, and the four commands CI runs. Folds in the CONTRIBUTING refresh that had been waiting for a release — it still called the project a "0.5 beta" and told contributors to configure an app registration before running anything, when no `.env` starts in Demo mode and needs no tenant. - Removed an orphaned screenshot (`docs/simulator.png`) that nothing referenced. ## [2.0.2] — 2026-09-09 ### Fixed - **Diagram: the Autopilot enrollment cards no longer go wrong as you change the endpoint.** Toggling **Autopilot device**, entering a Group Tag, or selecting and deselecting groups could leave the enrollment stack visibly broken. Three causes, all fixed: - **Cards vanishing.** Clicking a group on the diagram to hide it set a flag that was never cleared when that group left the simulation. Hide a group, uncheck **Autopilot device**, check it again, and the group came back *still hidden* — silently swallowing both enrollment cards, with only the small "Show all groups" chip to explain where they went. - **The stack hopping on every toggle.** Card heights were estimated from character counts (~40 chars/line for the device card, a fixed row height per setting) and corrected after render by reading the DOM, so every rebuild painted the stack at the estimate and snapped back a frame later — around 13px of visible jump per click at real-tenant group-name lengths. - **Stale exclusion text.** Hiding the group that excluded a profile left the card still reading "Would not deploy — excluded via \". The device card and its profile cards are now a single node laid out by the browser, each card carrying its own connection point so profiles still draw their own lines to the groups they target. Spacing is exact by construction rather than estimated and patched, exclusion is re-evaluated against the groups still shown (so hiding a group is a consistent "what if this group weren't here" lens), and the view refits when the set of nodes changes so newly added groups no longer land off-screen. ## [2.0.1] — 2026-09-06 ### Added - **Autopilot "+ conditions" surfacing in the group picker.** A dynamic group whose rule keys on Autopilot (a bare `[ZTDId]` clause) but ANDs it with a device condition the flat evaluator can't check — e.g. the common `… and (device.deviceModel -not -startsWith "Cloud PC")` (all Autopilot devices except Cloud PCs) — is no longer silently missed. When the **Autopilot device** toggle is on, it's surfaced with an `autopilot · + conditions` badge, its rule shown inline, and sorted to the top for one-click opt-in (not auto-selected, to avoid over-matching a condition that can't be evaluated). Cleanly-scoped `[ZTDId]` groups still auto-select as before. Mirrors the Group Tag "+ conditions" behavior. ### Fixed - **Diagram: exact spacing for the device + enrollment stack.** The Autopilot enrollment cards under the "Configured Endpoint" node are now positioned from their real measured heights (via a ResizeObserver), so the gaps are always even regardless of how the device's group list wraps — fixing an overlap that could appear on some selections. ## [2.0.0] — 2026-09-03 A major release centered on **legacy policy handling**, **cross-model conflict detection**, honest **dynamic-group (Group Tag)** surfacing, and a **public, zero-backend demo**. ### Added - **Cross-model conflict/overlap detection ("legacy policy collisions").** The same underlying CSP configured through *different* policy models — a legacy device-config template and a Settings Catalog policy, or an OMA-URI custom profile — used to slip past per-model detection entirely (`0 conflicts`, even though the device gets both). Settings now resolve a canonical `cspNode` — exact for Settings Catalog + OMA-URI via their real CSP path, and via a curated, Microsoft-docs-grounded template→CSP crosswalk for legacy templates — and any CSP touched by two models is surfaced as a collision (duplicate / conflict / verify) with a migrate-to-Settings-Catalog nudge. Best-effort over common settings, and labelled as such (a count next to conflicts/overlaps plus a "Legacy collisions" filter). - **"Legacy template" flag.** The deprecated device-config *template* types Microsoft is migrating to the Settings Catalog (Device restrictions/features, Endpoint protection, Extensions) are flagged in the Assignment Manifest and the merged baseline, nudging a Settings Catalog migration. The still-current templates (certificates, VPN, Wi-Fi, email, wired network) are deliberately not flagged. - **Real names, value labels and documentation links for legacy Endpoint Security intents.** Legacy intents' settings now resolve their true Intune display names and enum value labels from the template `settingDefinitions` (matching the Settings Catalog), plus direct Microsoft Learn links — instead of `definitionId` fragments and raw enum codes. - **Group Tag match transparency in the group picker.** Each dynamic group is classified against the entered Group Tag: a confident `[OrderID]` match (badged, auto-selected); a **+ conditions** match whose rule *also* hinges on a property a tag can't decide (shown with the membership rule to verify); or a **conditional** reference the flat evaluator can't confirm (surfaced, not auto-selected). Each group's `[OrderID]` scope is shown inline, matches fade in, and the list sorts selected → matches → conditional → rest. - **Public, zero-backend demo.** A static in-browser build runs the exact same engine over a bundled sample tenant — no server, nothing leaves the browser — hosted on GitHub Pages with a landing page, so the tool can be evaluated from a link. The demo tenant is grounded on the community Open Intune Baseline. - **Small-screen notice** so phone visitors get a "built for a bigger screen" message instead of a broken dense layout. ### Changed - **Only genuine security/baseline intents are labelled "(Legacy)".** Modern and migrated policies (which live in the Settings Catalog) can no longer be mislabelled as legacy Endpoint Security. - **Legacy template default noise removed.** Fixed-schema template profiles (device restrictions/general, endpoint protection, compliance) serialize their *entire* schema; the unset defaults — `false` toggles and the `notConfigured` / `userDefined` / `deviceDefault` / `unavailable` sentinels — are now dropped for those types, so a profile contributes only what it actually configures (a Device Restrictions profile that blocks 9 things reports 9, not ~200). Types where a `false` is genuinely meaningful (e.g. an Update ring's `allowWindows11Upgrade`) are left untouched. - **Group selector polish** — a cohesive tag-scope pill, muted structural chips, matched-row accent, and roomier list. - **Autopilot enrollment stacks under the endpoint.** The V1/V2 enrollment cards now sit directly beneath the "Configured Endpoint" node as one device unit (device on top, its profiles below) instead of occupying a separate column between the device and the groups — the endpoint *is* what enrolls, so it reads as one thing that branches out to its groups. Every group hangs directly off the device, with each profile drawing its own line to the specific device group it targets. ### Fixed - **Cross-model false positives on real tenants.** A complex/collection Settings Catalog setting expands into many sub-settings that all normalize to one CSP node within a single policy; the detector now requires two different policy *models* (not merely two setting ids), so intra-policy settings never fire. - **Docker/type-check:** added the missing Vite client types so the web workspace type-checks cleanly with the static-demo build. ## [1.2.1] — 2026-08-04 ### Fixed - **Selecting an Autopilot-joined group in the Manifest now carries it into the simulation.** The simulator's "Autopilot device" effect stripped any Autopilot-joined dynamic group (a bare `[ZTDId]` rule, e.g. "Device - Windows - Autopilot") whenever Autopilot mode was off — which it is by default on a Manifest handoff — so the group was silently dropped from the simulated device. The effect now only *adds* Autopilot groups when the toggle is on; it never removes a group the user selected deliberately (removal on toggle-off is handled separately). ## [1.2.0] — 2026-08-04 ### Added - **Per-group "seating charts."** As you check groups in the Assignment Manifest, a strip above the selection bar shows each group's policies as a bubble cluster — cool = a policy unique to this group, hot = one shared across many groups — with a containment bar ("68% its own"). Click a chart to open the full named policy list — heat-colored chips, unique policies first, each naming the other groups that carry it. A preflight read on whether a group is siloed or overcrowded with borrowed policies, computed entirely from the data already loaded. A policy that is on **both All Devices/Users *and* a direct group** — an assignment Intune's portal disallows (it's either/or), so an anomaly that can only come from Graph/PowerShell — is flagged distinctly (indigo ⚠) instead of being counted as the group's own. Pairs with the bridge: eyeball the groups, then simulate them. - **Manifest → Simulator bridge.** Check real Entra groups in the Assignment Manifest and hit **"Simulate a device in these groups"** to jump straight into the Endpoint Preflight simulator, seeded with exactly those groups — plus any device filters you were simulating in the Manifest — on the Manifest's current OS, as a fresh, ground-up merged baseline. A selection bar rises from the bottom when groups are checked; All Devices / All Users are shown as always-applies and included automatically rather than selected. Ties the tenant-wide "who carries what" view to the per-device "what does it get" view without merging the two screens. ### Fixed - **`Assignments@odata.context` no longer appears as a setting.** Fetching policies with `$expand=assignments` makes Graph attach an OData annotation (`assignments@odata.context`, a URL) that the schema-agnostic flattener emitted as a bogus setting — most visible on compliance policies, which have few real settings. Any `@odata` annotation key is now skipped. ## [1.1.0] — 2026-08-03 ### Added - **Legacy Endpoint Security & Security Baselines** (the older `deviceManagement/intents` model) are now read into the baseline — legacy **BitLocker / Disk Encryption**, **Defender Antivirus**, **Firewall**, **Attack Surface Reduction**, Account Protection, and EDR intents. Each intent's template supplies its category (BitLocker, Firewall, …) and platform; its settings are normalized like any other policy, so they merge into the endpoint baseline, appear in the Assignment Manifest, and — being Windows — join conflict/overlap detection (two legacy BitLocker intents disagreeing on the cipher is now flagged). Shown under a new **Endpoint Security (Legacy)** policy type — in the diagram, the Policy Types legend, and the Assignment Manifest — with a **Legacy policies** filter in the merged-baseline view to isolate what the legacy intents contribute. Modern Endpoint Security policies already came through the Settings Catalog; this closes the gap for tenants still on the legacy intents. The demo tenant gains legacy BitLocker (org-wide + a corp override that conflicts), Defender Antivirus, and an unassigned Firewall intent. ### Fixed - **Docker image no longer 502s on startup.** The shared package's entry point pointed at its TypeScript source (`./src/index.ts`), which dev tooling resolves but the compiled server in the slim runtime image cannot — the shared module failed to load at boot, the server never started, and every request returned 502. The package now resolves to its built output (`./dist` via `main`/`types`/ `exports`); `npm run dev` and `lint` build the shared package first so nothing regresses. No Dockerfile change was needed — both images already built and copied `packages/shared/dist`. ## [1.0.0] — 2026-07-20 First stable release. Everything below is relative to the 0.5 beta. ### Added - **Richer demo tenant.** The bundled sample now includes an Open Intune Baseline–style Windows set (openintunebaseline.com naming: Defender Antivirus, ASR, BitLocker, a ~35-setting Security Baseline, Firewall, Windows Hello, LAPS, compliance policies, browser admin templates, an update ring) so Demo mode reads like a real hardened tenant — ~25 policies, ~120 merged settings, two genuine conflicts (Defender submit-samples and feature-update deferral) and several overlaps, plus a fuller Policy Waitlist. The macOS set draws on the macOS Open Intune Baseline (FileVault, Firewall & Gatekeeper, Software Update, compliance), with a second iOS and Android policy so every platform tab has substance. No configuration is copied verbatim. - **Interactive assignment-filter simulation (Assignment Manifest)** — click an ⛃ assignment-filter chip (or the "Simulate device filters" toggles) to treat the simulated device as matching that filter. Every group recomputes live and cross-group: policies the filter drops move to a struck-through "Filtered" line showing why, and the applied counts update. Default (no filter matched) is the original broad view. - **Sortable Assignment Manifest** — the per-group rollup is now plain sortable Direct / Inherited / Incl-Excl columns (default: most-directly-assigned first), replacing an abstract stacked bar whose inherited half was ~constant noise. - **Autopilot enrollment layer** — Autopilot **V1** deployment profiles and **V2** device-preparation policies now appear as an intermediate stage between the configured endpoint and its Entra groups. Targeting is device-group based (V2 keys on its configured just-in-time device group, not the user assignment), exclusions are evaluated, and **dual targeting** (a V1 and V2 profile sharing one device group) is detected and shown. Cards are collapsed by default and expand to reveal the high-level deployment settings. - **Merged baseline data grid** — resizable columns, an Intune-style column picker (show/hide), a readable **CSP path** column with a **Microsoft Learn** documentation link per setting, copy-to-clipboard, and chunked rendering for large tenants. - **Human-readable Settings Catalog names and values** — setting names, and choice option values, are resolved from `settingDefinitions` ("Enabled" instead of a raw option id) rather than shown as concatenated definition ids. - **Demo mode** — the app starts against a bundled synthetic sample tenant when no `.env` is present (no app registration needed), with a **Demo / Connected** header toggle that flips the data source at runtime. - **Assignment Manifest** — a tenant-wide, per-OS view of which policies target which groups, with an in-app overlap summary (groups ranked by policy count), inherited All Devices / All Users and implied-membership rows (honoring "exclude wins"), and a CSV export. - **Policy Waitlist** — pull otherwise-unassigned policies into a simulation to preview "what if I assigned this?", shown as a distinct "No assignment" bucket. - Diagram: click a group to hide it and the policies it brings in; the layout reflows. - Group-by-type is the default diagram layout, with a **Detail view** toggle for the per-policy view. - Starter unit-test suite (Vitest) covering assignment resolution, the assignment report, dynamic-rule implication, Autopilot targeting, and Graph platform classification. - `SECURITY.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, PR/issue templates, and this changelog. ### Changed - **Diagram edge routing** — when Autopilot cards are present the endpoint connects only to the profile cards, and every Entra group (including the always-apply All Devices / All Users) hangs off the enrollment column: endpoint → profile → group → policies, with no duplicate lines crossing the Autopilot column. - **Faster large-tenant loads** — per-policy Graph detail calls run with bounded concurrency and honor 429/503 `Retry-After`, cutting first-load time on 150+ policy tenants substantially. - Consolidated the top bar (brand · view tabs · refresh) and removed redundant titles. - Debounced the simulation request to remove lag when switching OS or editing groups. ### Changed - **Autopilot-joined group detection now handles `or`, with operator precedence.** The "Autopilot device" toggle previously matched only a rule that was *nothing but* the bare `[ZTDId]` clause. In real tenants that clause is commonly a top-level `or` branch — often after a chain of `and` conditions. Since Entra binds `and` tighter than `or`, `A and B or ZTDId` means `(A and B) or ZTDId`, so an Autopilot device is a member via the ZTDId branch regardless of the `and` chain; those groups are now selected. The rule is split on top-level `or` only (never inside parentheses or quoted values), and a branch whose ZTDId clause is itself gated by `and` stays unselected — it would require a condition the evaluator can't check. Also accepts the paren-less and `-contains` clause shapes; a `[ZTDId]` pinned to a specific device still doesn't qualify. - **~Half the Graph calls on load.** Policy assignments are now fetched inline with each collection via `$expand=assignments` instead of a per-policy round-trip, cutting a 141-policy tenant from ~368 to ~188 calls (a healthy load ~9s). Fewer calls also means less exposure to Graph's transient server errors. The load summary now logs call/retry/throttle telemetry so a slow load is diagnosable. ### Security - **CSV exports are now formula-injection safe.** Cells beginning with `= + - @` (or a tab/CR) — which Excel/Sheets interpret as formulas — are prefixed with a single quote so a maliciously-named Intune policy or group can't execute when an admin opens an exported report. RFC-4180 quoting is unchanged. - **Dependencies updated to clear all known advisories** (`npm audit`: 0 vulnerabilities). Fixed transitive Fastify deps (`fast-uri`, `find-my-way`) and bumped `@azure/msal-node` to 5.x to drop a vulnerable `uuid`; the client-credentials auth flow was re-validated against a live tenant. ### Fixed - **Unrelated groups no longer show as "implied by rule".** Implied-group detection reported an implication whenever the two rules shared *any single* clause, so two complex real-world rules that both happened to be scoped to `deviceOwnership -eq "Company"` implied each other — a Windows group surfaced a macOS group as implied, in both directions. Implication is now restricted to rules built solely from `devicePhysicalIds` clauses (where a Group Tag prefix relationship is genuinely provable); any rule mixing in another property claims no implication. Also requires *every* branch of an `or`-chain to imply, not just one — previously a group whose other branch didn't qualify was still reported. - **Include vs exclude assignment filters now read correctly.** A policy dropped by an *include* filter (the device doesn't match the filter it's scoped to) was described with the same "excluded via device filter" wording as an *exclude* filter (the device matches) — the opposite reason. Every surface (merged baseline, diagram, manifest) now states the direction-correct reason and, for include filters, hints that selecting the filter under Device Filters simulates a match. The demo tenant gained an All Devices + include-filter policy to showcase it. - **Reliable large-tenant loading — no more silently dropped policies.** The Graph client now retries gateway timeouts (502/503/504) and dropped connections in addition to throttling (429), with a per-request timeout so a wedged connection can't stall the whole load for minutes. Previously an unretried gateway timeout threw, and the failed policy (or whole category) was skipped silently. If a load still can't complete an item after retries, it's surfaced — a banner in the app and a summary in the server log — so a partial baseline is never silent. - **macOS compliance no longer "hallucinates" settings.** A compliance policy's Graph resource is a fixed schema that serializes every field with its default, and the untouched defaults (`false` booleans, `deviceDefault` / `unavailable` enums) were leaking in as configured settings — and, across an OIB-style one-concern-per-policy layout, inventing conflicts. Compliance policies now contribute only the rules they actually enforce. - **Conflict and overlap detection is now Windows-only** (previously just overlaps). Value-level comparison isn't trustworthy on other platforms yet, so both flags are suppressed off Windows; the merged baseline is still shown everywhere. See ROADMAP.md. - Assignment Manifest now counts **applied** policies (direct + inherited) for its bar level, instead of conflating distinct direct targets with inherited includes. ## [0.5.0-beta] — 2026 Initial public beta. ### Added - Endpoint simulator: pick an OS, Entra groups, Autopilot / Group Tag, and Assignment Filters, and see the merged CSP baseline with conflicts and overlaps. - Group → policy → setting diagram (React Flow) with a drill-down merged-baseline panel and JSON/CSV export. - Read-only Microsoft Graph client (app-only auth) with in-memory TTL caching, covering configuration profiles, compliance, Settings Catalog, Administrative Templates, platform scripts, Windows update profiles, Autopilot profiles, and Assignment Filters. - Docker Compose and `npm run dev` setup; MIT licensed. [Unreleased]: https://github.com/kevinmalinoski/intune-preflight/compare/v1.2.1...HEAD [1.2.1]: https://github.com/kevinmalinoski/intune-preflight/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/kevinmalinoski/intune-preflight/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/kevinmalinoski/intune-preflight/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/kevinmalinoski/intune-preflight/compare/v0.5.0-beta...v1.0.0 [0.5.0-beta]: https://github.com/kevinmalinoski/intune-preflight/releases/tag/v0.5.0-beta