Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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
  `<html>` and `<body>` 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<string>`: 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 \<hidden group\>".

  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