# Changelog

All notable changes to shopdb-flask are recorded here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The product version (`__version__`) and the plugin-contract version
(`__contract_version__`) are distinct series with independent bump rules; see
ADR-007 and ADR-002.

## [Unreleased]

### Added

- **The code generator can print a back side.** A badge card is two-sided and
  the generator only ever emitted one page per card. The Back side section
  turns on a second page per card carrying either the same code again, a fixed
  block of text, its own per-row content (CSV `back` / `backlabel`), or
  nothing. Page order is a choice, because the two kinds of printer want
  opposite things: **interleaved** front-back-front-back for a duplex card
  printer, **grouped** all-fronts-then-all-backs for a single-sided printer
  that gets its stack flipped and reloaded. Getting that backwards prints
  perfectly readable cards with the wrong back on every one, which is why the
  order is pinned by tests rather than by looking at the preview.
- **The gap between a code and its caption is its own control.** It was the
  quiet zone doing double duty: closing up a caption meant shaving the clear
  space a scanner needs, and the only way to get a code to scan was to push the
  caption away from it. Quiet zone now means only the code's own margin; gap
  means only the distance to the caption, and adds to the quiet zone rather
  than eating into it. Every stock preset carries a gap that suits it.
- **Contents can be positioned inside the label without moving the label.**
  Align and Vertical place the code and caption block anywhere on the stock,
  and Content X/Y nudge it from there. The existing X/Y nudge kept its
  behavior and is now labeled Media X/Y: it moves the whole printed area, for
  a printer whose origin is off, which is a different problem from composing a
  label.

- **A picture can go on the label.** Pick an image once and it prints on every
  label - above the code, beside it, or behind everything as a watermark - on
  the front, the back, or both. The file never leaves the browser, so an
  air-gapped site can use it, and it is deliberately not saved between visits:
  a data URL runs to megabytes and would take every sizing value with it when
  the storage quota gave out.
- **The back can carry its own code, in its own symbology.** "Back carries" now
  defaults to a code rather than text, takes per-row content from the CSV
  `back` column, and has its own QR/CODE128 choice - a QR on the front that
  opens the record, a CODE128 on the back that the bench scanner already reads.
  A back set to carry a code can no longer be laid out as text-only: that
  combination printed a blank card that looked like the code had failed, and it
  is corrected on load for anyone who already saved it.

### Fixed

- **The generator prints from inside the app shell.** It was a full-screen
  route with no sidebar and a narrow column of controls on the left. It is now
  an ordinary page - navigation, full width - and `style.css` hides the shell at
  print time, so the stock still gets nothing but labels. Verified against a
  real print PDF rather than a preview.
- A test print paired each page with a code by loop position while the codes
  are keyed to position in the whole run, so a two-sided test card could carry
  the wrong code. The page index now travels with the page.
- The back label's dotted preview outline outspecified the print reset and drew
  a dashed rule down the edge of every second card.

### Changed

- CSV parsing and page building moved out of the generator component into
  `plugins/tools/frontend/labelPages.js`, where the column contract and the
  two-sided page order are covered by tests.

## [0.12.0] - 2026-08-20

Printers become a property of the bay rather than of whoever last walked up to a
PC, and the container stops throwing away the site's own files on every update.

**Air-gapped sites need a fresh offline bundle for this one.** The stack moves to
MySQL 8.4 LTS, and an existing tarball only carries the 8.0 image, so
`docker compose up` would ask for an image the site cannot pull.

### Added

- **Printer assignment has a form.** `PrinterAssignmentPicker` is the ONE picker
  for both ends: MachineForm gained it (the machine is where the assignment
  belongs and there was no way to set it except the relationships card), and
  PCForm now uses the same component instead of its own copy, so the two sides
  of an override cannot drift. PCForm also stopped reconciling row at a time
  through the generic relationship endpoints, which left a PC half-assigned on
  an HTTP failure; it calls
  `PUT /api/printers/assignments/for-asset/<id>` instead.
- **A relationship type can declare itself singular**
  (`relationshiptypes.issingular`, migration 7d34), and `defaultprinter` does.
  Setting a second default REPLACES the first. The unique constraint is
  (source, target, type), so two different targets were two valid rows and the
  resolver took the OLDEST: a new default silently lost.
- **Observed printer queues.** `POST /api/collector/printers` (the ADR-006 hook,
  no new transport and no new credential) records what a bay ACTUALLY has in a
  plugin-owned table, kept strictly apart from what it is assigned. Adoption is
  explicit via `POST /api/printers/assignments/seed-from-observed`, which routes
  through the same reconcile path as the editor and REFUSES a queue matching no
  known printer. New client script `Report-PrintersToShopDB.ps1`.
- **Wave-gated driver rollout.** `Install-ShopdbPrinterDrivers.ps1` takes
  `-WaveStart`, `-Waves`, `-WaveUnit` and `-IgnoreWave`. GE-Enforce offsets each
  PC by SHA256(hostname) % 5 MINUTES, which was sized for a JSON check, not a
  100 MB driver set: ungated, ~300 bays pull ~30 GB inside one five-minute
  window on the share the whole floor depends on. Each bay derives its wave from
  its own hostname, the gate runs BEFORE the manifest is read (the manifest is
  on that share too), and it fails closed on an unparseable date.

### Changed

- **MySQL 8.4 LTS** in both compose files, and as the default in
  `build-offline-bundle.ps1`. 8.0 reached end of life in April 2026, which
  `INSTALL-WINDOWS-IIS.md` already said while the stack still pinned it; the two
  halves of the product disagreed about which server a site should run.
  Verified against a real 8.4.11 server rather than by changing a tag: the core
  chain and five plugin chains applied clean, 66 tables at a single
  `utf8mb4_unicode_ci` collation, and the image's PyMySQL authenticates against
  8.4's `caching_sha2_password` (which is why `cryptography` is pinned).
  Sites upgrading an EXISTING server should note that 8.4 removes
  `mysql_native_password`: an account created on 5.6 or 5.7 must be moved to
  `caching_sha2_password` before the upgrade or it cannot log in afterwards.
  Air-gapped sites need a fresh offline bundle, since the tarball carries the
  MySQL image.
- **The database healthcheck probes over TCP** rather than the unix socket. The
  entrypoint's init pass answers on the socket while running the server with
  `--skip-networking`, so a socket ping reported healthy DURING init and
  `depends_on: service_healthy` released `api` and `migrate` against a server
  that was about to restart. Found by having it happen: a probe passed at 8s and
  the next query failed because the server was mid-restart.

### Fixed

- **The image did not build.** `npm run build` fires a `prebuild` hook that runs
  `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend into
  the Vite tree and codegens `routes.gen.js`. The Dockerfile's frontend stage
  copied `frontend/` alone and flattened it to the stage root, so that path
  resolved to `/scripts` and every build since the staging script landed died on
  `Cannot find module`. The stage now keeps the repo-relative layout and copies
  `scripts/stage-frontend.mjs` and `plugins/` in beside it. Verified end to end:
  16 plugin frontends staged, `frontend/dist` in the final image.
- **The compose stack did not persist `instance/`.** `db_data` was a volume and
  the instance directory was not, so `docker compose build api && up -d api`
  recreated the container and discarded `plugins.json` along with every upload:
  floor plans, branding, model and application images, employee photos,
  warranty proofs, slides, printed-part files and the Dell OAuth token. The
  visible symptom was a site coming back with its plugins disabled. Both compose
  files now mount an `instance_data` volume (the air-gap `migrate` service too,
  since `plugin upgrade-all` writes `plugins.json`), and DEPLOY.md carries the
  one-time rescue for a stack that predates it.
- **A drifted print queue is corrected, not just a missing one.** Queues were
  matched by NAME alone, so a bay whose printer had moved or whose queue was
  built on a replaced driver looked converged and printed to the wrong device.
  `Set-ShopdbPrinters.ps1` now repoints a wrong port and swaps a wrong driver
  IN PLACE with `Set-Printer`, so the queue keeps its name, sharing, permissions
  and whoever holds it as their default. The driver is only swapped when the
  wanted one is staged, and there is still no removal path in the script.
- **MachineForm's dropdowns all came up empty.** It read `.data.data` off
  `computersApi.listAll()`, which already resolves to the array, so the whole
  parallel load threw into the catch and the machine's own values never loaded.
- **The legacy import loader dropped `machines.printerid`**, the classic
  system's record of each machine's default printer, so the production import
  would have lost every one.

## [0.11.3] - 2026-08-19

Fixes the last of the buildings-and-levels bugs, and brings the shop-floor
clients into the product: the collector reporter, EventSaver and a printer
assignment feature that lets a bay install its own printers.

**Anyone on 0.11.0-0.11.2 should take this.** The hover preview reported "this
asset has a position but no level" for every asset, and the map PDF printed
markers from every floor onto one sheet.

### Fixed

- **Every hover mini-map said "no level".** When 0.11.0 gave LocationMapTooltip a
  levelid, none of its seven call sites were taught to pass one - printer,
  machine and PC detail, the toner report, enforcement reports, the warranty chip
  and the dashboard cards. Two payloads behind them also emitted coordinates with
  no level.
- **Map PDF export printed other floors' markers.** It plotted every filtered
  asset onto one sheet, which is worse than the on-screen version was: nobody can
  correct a sheet once it is printed and carried onto the floor.
- **The legacy import loader created undrawable markers**, sending mapleft/maptop
  with no level at three call sites. That loader is the one still to run against
  production.
- The build gate that should have caught all of this asked whether a FILE
  mentions `levelid`, not whether each position does - one module emitted `mapx`
  six times and `levelid` once and passed. It now checks per occurrence, covers
  `scripts/`, and fails any Vue file that binds tooltip coordinates without a
  level.

### Added

- **Printer assignment.** Printers belong to the MACHINE and reach whichever PC
  controls it, so a reimaged or swapped PC comes back with the bay's printers and
  nothing had to be saved off the old one. New `usesprinter` relationship type,
  propagating through `controls` at read time; `GET /api/printers/for-host/<host>`
  for the client; `PUT /api/printers/assignments/for-asset/<id>` to reconcile a
  whole set in one call.
- **`printerdrivers.vendorid`**, so one row serves a make. HP and Xerox universal
  drivers cover 41 of the reference site's 44 printers; binding a driver to a
  single model meant 21 near-duplicate rows. Resolution is model, then vendor,
  then the pre-vendorid name convention.
- **Client scripts** in `plugins/printers/client/`: stage a site's driver set
  (`Install-ShopdbPrinterDrivers.ps1`, with `-TestOnly` for DSC), converge a
  bay's queues (`Set-ShopdbPrinters.ps1`), and apply the per-user default
  (`Apply-ShopdbDefaultPrinter.ps1`).
- **The collector reporter is in the repository** at
  `plugins/computers/client/`, with no site in it, and the server generates a
  copy carrying this site's URL and ranges: Settings > Computers > Asset
  reporter, or `GET /api/computers/client-script`. The collector key is
  deliberately never stamped into it.
- **EventSaver source** at `plugins/slides/client/`. The site UNC that was
  compiled into it is gone; a missing ini now fails visibly rather than pointing
  a new site at the reference site's file server.
- Settings: `computers_routableranges` (replaces two hardcoded VLANs) and the
  buildings-and-levels admin gains editable width AND height.

### Changed

- The map editor accepts a click. Its handler was bound only if the map was
  already a picker when it mounted, and the editor opens with nothing selected,
  so placing a marker by hand was impossible for the life of the page.
- Global search orders every query before truncating, so the same search cannot
  return a different subset twice.

## [0.11.2] - 2026-08-17

Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it.
Anyone on 0.11.0 or 0.11.1 with more than one level should take this: until now
the map drew markers from every level on whichever floor plan was showing.

### Fixed

- **Markers from other levels were drawn on the displayed level.** The map
  component filtered out positions with no coordinates and drew everything else,
  never checking which drawing a position belonged to - the exact failure ADR-017
  exists to prevent, in the one component that draws the map. Markers are now
  filtered to the level being shown, and a position with no level is omitted
  rather than approximated onto the default.
- **The map page had no way to choose a level.** It read the current level only
  to title a PDF export, so a second floor was unreachable from the viewer most
  people use. A level selector appears when a site has more than one, and
  switching moves the drawing, the bounds, the coordinate space and the markers
  together.
- **The map editor never accepted a click.** Its click handler was bound only if
  the map was already in picker mode when it mounted, and the editor opens with
  nothing selected, so selecting an asset and clicking did nothing at all for the
  life of the page. The asset forms were unaffected, because their picker is
  mounted inside a dialog that is already in picker mode.
- Existing markers are now drawn while placing one. Positioning an asset relative
  to the machines already on the floor is the whole task.
- Searching the map follows results across levels: a search whose matches are all
  on another floor showed an empty map while the filter counted them.
- **A level's native size could not be set.** The settings page had a width field
  and no height field at all, and locked both once any marker was placed - which
  blocked the case the feature was built for, a new blueprint of new dimensions
  on a floor that already has markers. Both fields are editable, with a
  confirmation and a pointer to landmark recalibration.
- **The same search could return different results.** Fourteen of the sixteen
  searchers behind global search truncated with `LIMIT` and no `ORDER BY`, so the
  database was free to return a different subset of matching rows each time.
  Every searcher now ends in a total order.
- Re-searching a term already in the address bar did nothing: the router treats
  it as a duplicate navigation and aborts, so the results page never re-queried.
  This is why the sidebar box and the results-page box appeared to disagree.
- The empty legend no longer draws its bar and border, which read as a stray
  input box under the map toolbar.
- The map editor's panel header no longer crams a heading and three controls onto
  one row, which had squeezed the search box until its placeholder read
  "Search na".
- Both map pages had a scrollbar. They subtracted the wrong amount from the
  viewport height for the page chrome, so each overflowed by the difference; the
  padding is now a value the pages and the layout share.

## [0.11.1] - 2026-08-17

A patch release: the pagination-cap fixes, plus the documentation that missed the
0.11.0 installer. No application behaviour changed on the server and the plugin
contract stays at 0.20.0.

`docs/UPGRADE.md` is one of the documents the Windows installer puts on the box,
and its buildings-and-levels section landed after 0.11.0 was tagged - so 0.11.0
shipped the page an operator reads WHILE upgrading without the guidance about
that upgrade. An air-gapped server has no other way to reach it.

### Fixed

- Pickers and batch label sheets no longer stop at 100 rows. `perpage` is
  clamped to `MAX_PAGE_SIZE` (100) server-side and the response says nothing
  about it, so a control asking for 500 or 1000 received the first 100 rows and
  a success. On a site with 126 applications the 26 sorting last could not be
  chosen as a knowledge-base topic; batch label printing rendered a sheet for
  the first 100 of 262 machines that looked complete. New `fetchAllPages()`
  helper plus `listAll()` on the applications, models, machines, computers,
  printers, network, measuring-tools, USB and printed-parts APIs, used by the
  KB article form and topic filter, the notification form, the report filter
  builder, `AssetLabelBatch`, `PrinterQRBatch`, `USBLabelBatch`,
  `PrintedPartsLabels`, `MachineForm` and `NetworkDeviceForm`. Lists that render
  a page at a time are unchanged - they page deliberately.

### Changed

- The installer now also ships `FLOOR-MAP.md`, the operator's page for loading
  floor plans, placing markers and moving them when a plan changes. Same reason
  the Windows runbooks ship: the box cannot reach the wiki.
- The bundled `UPGRADE.md` now carries the 0.11.0 section: an existing map is
  preserved and nothing moves on screen, and replacing a blueprint with one of
  different dimensions moves every marker on that level, so recalibrate from
  landmarks rather than editing the level's width and height.

## [0.11.0] - 2026-08-17

The floor map became a set of drawings instead of one picture. A site can hold
more than one building, a building more than one level, and every map position
now records which drawing its coordinates belong to. Driven by a real move: a
second floor was added, a new blueprint changed size, and machines relocated.

Alongside it, a sweep of what search can actually reach. Two identifiers the UI
collects were findable in almost no way, and two plugins' records were findable
in no way at all.

### Added

- Buildings and levels (ADR-017). Each level owns its own blueprint per theme
  and its own native pixel size; `assets.mapx`/`mapy` are pixels of the level
  named by the new `assets.levelid`, not of the site.
- A bulk repositioning tool on the map editor: filter by unplaced, by
  needs-review, or by level, search, then place markers and confirm them. New
  `POST /api/mappositions/positions` and `/verify`.
- Landmark recalibration (`POST /api/mappositions/transform`). Name two or more
  points that appear on both the old and new drawing and every marker on the
  level moves onto the new one. Defaults to a dry run that reports each old and
  new position and anything that would land off the drawing. The transform is
  solved PER AXIS from the landmarks, never derived from image dimensions: a
  taller drawing that gained a level below did not rescale, and a
  dimension-derived scale would stretch Y by 1.57 and be wrong everywhere.
- Position snapshots with restore, taken before any bulk change including a
  restore, so an undo is itself undoable.
- A buildings and levels admin under Settings: name and order levels, see each
  level's id and marker count, upload a blueprint per theme, choose the default.
- `assets.mapverifiedat`, the record of when a position was last confirmed
  against the current drawing. A bulk transform clears it, because a transform
  is a starting guess and nothing in the coordinates says which markers moved.

### Changed

- Plugin contract to 0.20.0 (additive; see CONTRACT-STABILITY.md). A plugin that
  writes a map position must now write its level.
- A position whose level is unknown renders as "level unknown" and is NOT drawn
  on the default level. Drawing it there would look entirely correct while
  pointing at the wrong part of the building.
- `GET /api/printers/install-list?format=text` gained `levelid` as a NINTH
  field, appended. Fields 0-7 are unchanged because the shipped Pascal installer
  reads them by index.
- Knowledge base: an article whose topic is a retired (`isactive = 0`)
  application no longer appears in listings, searches or the counts, and the
  topic picker offers every active application rather than only installable
  ones.

### Migration

- `7d33_buildings_and_levels` converts an existing single-map site: it creates
  one building and one default level carrying the blueprint paths and dimensions
  from the old `map_*` settings, then assigns every already-placed asset and
  location to it. Nothing moves on screen. The old settings rows are left in
  place so a rollback still finds them. Verified end to end on MySQL 5.6 from a
  production-shaped database.

### Fixed

- `gaugelabreference` was matched only on measuring tools and
  `maintenancereference` was matched nowhere at all, for any asset type, while
  Settings offers both identifiers on machines, PCs, printers and network
  devices. Both are now matched by global search AND by every asset list's
  `?search=`. An identifier that can be entered has to be findable, or it is a
  write-only field, and the tag is read off the physical machine by someone
  looking at the list page.
- USB devices and printed items were unreachable from global search entirely.
  Neither is an asset, so the generic asset search could not see them and no
  searcher existed. They now match on serial, asset tag and label, and on bin
  code (`itemcode`), gage-lab tag, name and description respectively - both
  honouring `isactive`, both with Settings toggles, result labels and filter
  chips.
- Global search returned knowledge-base articles whose topic application is
  retired, while `GET /api/knowledgebase` hid them. A filter that only some paths
  apply is not a filter: the article stayed two keystrokes away and the result
  printed the retired application as its subject.

### Known gaps

- The Pascal printer-installer map still compiles in one drawing's dimensions and
  bundles one blueprint, so it is accurate for the default level only. See
  section 6 of PRINTER-INSTALLER.md.
- The setup wizard still asks for a single site-wide blueprint and writes
  settings that no longer drive the map.
- Map PDF export covers the current level only.

## [0.10.0] - 2026-08-17

A security release. The Windows installer left the directories it creates
writable by every logged-on user, which made two of the files it puts there a
local privilege escalation; that is fixed and verified on Windows. Alongside it,
the work that makes this product genuinely adoptable by a second site: one
site's hostnames, share paths and networks are out of the product code and out
of the published documentation, and the rule that keeps them out now fails the
build rather than printing a warning nobody read.

The plugin contract moved to 0.19.0, which is a BREAKING change - see the
Changed section and CONTRACT-STABILITY.md before upgrading a plugin you wrote.

### Added

- Applications accept an uploaded image and an uploaded installer, rather than
  only a path typed into a text box. The path field kept working only while
  someone maintained a share by hand, and a link to a file nobody could still
  find was indistinguishable from a link to one that worked. The installer
  download is gated on `applications.view` because it is licensed vendor
  software; the image is public, since tiles render before login.
- Warranty records accept an uploaded proof document - the invoice or
  certificate that actually settles a claim. The documentation path stays as it
  was: it points at a vendor portal or a share, which an upload cannot replace.
  Downloads require `warranty.view`, since a proof carries pricing and a service
  tag, and it comes back under the vendor's own filename so it is recognisable
  in a support ticket.
- Toner forecast report: days until each cartridge runs out, soonest first,
  from Zabbix level history. Ordering by days left rather than by level is the
  point - a cartridge at 60% draining fast needs ordering before one sitting at
  8% that has not moved in a month. It also counts how many cartridges each
  printer has been through in the window, which is the number a purchasing
  conversation asks for. Printers with no honest estimate are listed
  separately with the reason (too little history, gauge has not moved, replaced
  recently) instead of being sorted in as zero or as safe, and an unreachable
  Zabbix says so rather than rendering an empty table that reads as "nothing is
  due".
- Fiscal week under the site name in the sidebar and on the shop-floor board,
  as the classic ASP site showed it. People quote a week number across both
  sites, so the arithmetic is a port of the old one (ISO 8601: week 1 contains
  4 January, the week's Thursday decides the year) rather than a fresh
  interpretation.

- Backup history distinguishes CAPTURED from LAST VERIFIED. Captured is when a
  configuration was written; verified is when a PC last posted it and the hash
  still matched. They differ by design, because an unchanged config writes no
  new revision - so a machine checked daily for a year shows one revision from
  last spring, and previously read as abandoned. Shown on the history page and
  on the asset panel; only the current revision carries it, since an older one
  is not what the PC holds now.
- A backup now records that it was CHECKED, not only that it changed. Dedup
  means an unchanged configuration writes no revision, so the stored timestamps
  moved only on a change: a machine whose settings had been stable for six
  months was indistinguishable from a machine whose backup died six months ago.
  The only evidence a backup still ran was a line in a log file on the PC.
  `lastseenat` is touched on every matching post, including the no-op, and is
  backfilled from the timestamps that already exist.
- The low-supplies report lists only the cartridges that need replacing. A
  printer with one empty black and three full colour cartridges was listing all
  four, so the reader had to hunt for the problem inside the row. The report
  exists to answer "what needs replacing".
- Dashboard cards for enforcement failures, machine numbers claimed by two PCs,
  PCs not reporting, backups that have stopped, printer supplies, expiring
  warranties, machines out of service, and the notifications currently showing
  on the floor. The dashboard now renders cards plugins declare, which it never
  did before - five plugins had been declaring widgets into a void, pointing at
  components nobody wrote. Cards are ordered by severity, hide themselves when
  there is nothing to report, gate on a permission, and each fetches
  independently so one broken endpoint cannot blank the board.
- Tech Tools: a section for the small conversions and code generation that
  otherwise happen in a browser tab on somebody's phone. Starts with barcode and
  QR codes laid out for the label stock actually in the printers.
- Kiosks report what they are. The dispatcher already read
  `C:\Enrollment\display-type.txt` to pick its target; now it tells ShopDB, so
  the Asset column on an enforcement report shows a reported value instead of
  one inferred from a mapping table nobody filled in.
- Enforcement reports carry what the PC is, where it is, and whether it is
  backed up: the host links to its own page, the Asset column names the machine
  or instrument the PC controls, a map pin shows the location on hover, and the
  backup column judges the chain rather than printing its last date.
- `flask relationships audit`, read-only, listing links that cannot both be
  true, plus an inverse guard that refuses to create one.
- An employees endpoint that says WHY a name did not resolve, rather than
  returning nothing and leaving the caller to guess.
- The display client module updates itself through the manifest, so a client
  change no longer means hands on every kiosk.
- `docs/START-HERE.md` routes a reader by what they came to do, and is now the
  wiki's landing page - which previously recommended the one install document
  the docs themselves forbid for a new site. `docs/FLEET-ARCHITECTURE.md` is the
  page nothing else could assume: how the server, GE-Enforce, the asset
  reporter, the backup collectors and EventSaver relate, where credentials live
  at both ends, and which of the five to open for a given symptom.
- `docs/ADOPTING-AT-ANOTHER-SITE.md`: how another site repoints the asset
  reporter and the EventSaver screensaver at its own ShopDB, with worked
  examples for Intune (including Machine Configuration/DSC), a GE-Enforce
  manifest entry, and manual installation. Both tools were already built to be
  repointed - the URL, the key and the targeting are inputs - but nothing said
  so, and the two things that catch people out are written down: EventSaver's
  compiled-in fallback path is not neutral, and a config enforced by hash
  reverts a hand edit by design.
- `docs/PROJECT-MAP.md`, generated by `scripts/gen_project_map.py`: versions,
  the plugin inventory, every Alembic chain head, the ADR index and the size of
  the codebase, derived from the code. The hand-written equivalents had drifted
  by two contract releases and 400 tests.

### Changed

- Plugin contract **0.19.0**: `get_dashboard_widgets` returns data and shape,
  not a component name. The old shape named a Vue component per widget, which
  cannot survive a lean build (ADR-013) and had five plugins pointing at
  components nobody wrote. The change itself shipped earlier without a bump and
  with `BasePlugin` and PLUGIN-HOOKS.md still teaching the dead shape; this
  records it and corrects both.
- Plugin contract **0.17.0**: `DashboardDefault` is part of the `shopdb.api`
  surface, so a plugin can resolve a display without reaching into core.
- Plugin contract **0.18.0**: one name per display role, and it is the kiosk's
  own (`Dashboard`, `Lobby`, `3DPrintRoom`). Server and dispatcher had three
  vocabularies for the same three kiosks, and a display could report a role core
  could not store. `normalize_display_role` resolves case and the retired
  spelling; an unknown value is kept verbatim so a typo stays visible.
  Contract versions are their own series (ADR-002) and belong in this file;
  0.17.0 and 0.18.0 shipped without an entry, which is what this note corrects.
- Publishing a GE-Enforce manifest is refused when PCs reporting for that scope
  run an older client library than the manifest needs, naming the hosts. A minor
  version bump that NARROWS behaviour is not backward compatible: an old library
  reads a gated entry as ungated and installs every version it cannot detect.
- Shadow mode runs inside the enforce cycle instead of on its own schedule. The
  share is mounted only for the length of a cycle, so a task on its own clock
  woke to a missing drive and reported 0 installed / 0 skipped / 0 failed, which
  is indistinguishable from a healthy no-op.
- A GE-Enforce report is judged stale on both clocks, the server's and the
  client's asserted check-in, so a PC cannot mask real silence with a future
  timestamp. A PC that has gone quiet stops reading as healthy.

### Fixed

- Documentation a reader could not follow. 98 curl examples single-quoted
  `$TOKEN`, so the shell never expanded it and the server answered 422; a JSON
  body carried a `//` comment, which made the server report a missing hostname
  about a body that has one; the IIS install omitted `flask plugin upgrade-all`,
  which is the 1054 error a deploy hits days later; a pilot loop exited 1 on
  every iteration; and the external-plugin guide pinned a contract version six
  releases stale, so a plugin built by following it is refused at startup. Nine
  documents now point at the generated project map rather than typing a version,
  and a test enforces that. The API inventory gained the 22 routes that were
  serving traffic undocumented, with a parity test against the live URL map so
  the next one cannot slip in silently.

- **Every plugin migration chain re-ran its head on each MySQL deploy.** Core's
  Alembic env got a `connection.commit()`; the per-plugin template did not, so
  the last migration of every run lost its version stamp while its DDL survived.
  Invisible for as long as every head happened to be idempotent - and two were
  not. The backups head cleared a column for every row, and the geenforce head
  added a column unconditionally and failed on the second attempt. Both are now
  bounded and guarded, and the template commits.
- A part-marker PC reported 200 on the cycle that created its marker and 500 on
  every cycle after. Both of the links it makes were looked up by label, so a row
  made by hand or by the legacy import was invisible and the insert violated the
  uniqueness of (source, target, type); and the marker's asset number is derived
  from the PC, so it could already be taken.
- A CMM reported the same way: the instrument IS the reported bay, so adoption
  was relabelling the row the machine sync owns and finding it by that label.
- A reported identifier is matched exactly rather than with `ilike`, so `_` and
  `%` in a file on a shopfloor PC are no longer wildcards. `MT-600_` had been
  adopting MT-6001, and a bare `%` adopted whatever active asset came first.
- Measuring tools are adopted before they are minted. Minting derived the asset
  number from the HOSTNAME, so a permanent instrument took the identity of
  whichever PC drove it that week, and the collector could not see a tool it had
  not created - which left legacy instruments shadowed by minted twins.
- An unanchored rsync exclude in the export was matching at any depth, so a
  whole plugin was silently dropped from the published payload. Root excludes
  are anchored and the export now aborts if a plugin manifest fails to survive.
- Backup revisions are ordered in Python, not with `ORDER BY ... NULLS LAST`,
  which SQLite accepts and MySQL rejects.

### Security

- The Windows installer breaks ACL inheritance on the roots it creates. A
  directory made directly under `C:\` inherits an inherit-only Modify grant for
  Authenticated Users, so `C:\shopdb-flask`, `C:\Python314` and `C:\MySQL84`
  were writable by any logged-on user. Two files inside them make that a local
  privilege escalation: `shopdb-admin.ps1`, which self-elevates, and
  `instance/config.py`, which the app executes unconditionally. The MySQL data
  directory is locked down as well, since it holds the password hashes. A Python
  installation this installer did not create is reported rather than seized.
- Site-specific values no longer ship in product code (ADR-015). The backups
  share root defaults to blank instead of one site's file server, the shadow
  client no longer overwrites a site's own ShopDB URL in the registry on every
  cycle, the kiosk dispatcher takes its URL from the `site_base_url` setting and
  refuses rather than guessing, and one site's hostname, FQDN and internal
  subnets are out of the documentation, the generated API specs and the UI
  placeholders. The ADR-015 scanner now covers PowerShell, the installer, the
  seeds and generated JSON, case-insensitively, and FAILS the build; deliberate
  organisation-wide defaults are declared with an `ADR-015-OK` marker. Both
  publication gates carry the site patterns.

## [0.9.0] - 2026-08-11

Driven by a fleet that had been failing quietly. A bay had been returning 500
to every collector report since it was imaged, backups were accumulating
duplicate revisions on any machine number more than one PC reported against,
and a setting that looked configurable had never been readable by the script
that consumed it. The theme of the release is making those failures visible:
each fix ships with the check, report or document that would have surfaced it.


### Changed

- The toner forecast is read as an order, not as a table. It opens with what to
  buy - grouped by part number, with a quantity - because two cartridges of the
  same part in different printers is a quantity of two, and that was a number
  the reader had to work out by hand. Below it, cartridges sit in urgency bands
  (empty / two weeks / thirty days / later) rather than in one long sortable
  list, since the question being asked is which pile a thing is in. A row is
  now a cartridge rather than a printer, carries its own part number and a
  level bar, and everything past "empty" starts collapsed. Cartridges with no
  part mapped are counted on one line instead of a line each: they still have
  to be ordered, but not from this page - the job is to map them.

### Fixed

- The toner forecast was reading the wrong end of the window. Zabbix applies a
  query's `limit` to the whole answer rather than to each item, and combined
  with an ascending sort that kept the OLDEST rows: a four-cartridge printer
  polled every five minutes writes over 100k readings in 90 days, so a "90 day"
  forecast was fitted to the first few days of the window and nothing since.
  That is where "4 days left" beside a cartridge at 20% came from - the rate
  was real, it just described a cartridge thrown away in May. Long windows now
  read hourly trends, which is the table meant for this and is a tenth of the
  rows; short ones read raw history, newest first, with a budget scaled per
  item.
- The forecast now counts down from the level it displays. It showed the live
  reading but computed days-left from the last stored one, so the two could
  disagree by a whole cartridge. A live level far above the stored run is
  treated as a swap that happened since the last reading rather than as a
  collapse in the burn rate.
- A cartridge at or below 5% reads as empty rather than as a slow drain. At 1%
  losing a tenth of a point a day the arithmetic said ten days; the printer is
  out of toner, and it is the first thing that should be ordered.
- Days-left in the forecast is per cartridge again. It was rendered in a cell
  spanning the printer's rows, which put the printer's soonest figure beside
  every supply it had - a cartridge at 20% displaying "4 days" that belonged to
  the black beside it, and a cartridge at 1% displaying weeks that belonged to
  nothing on that row at all.
- Supplies typed as floats were dropped from the forecast on any printer that
  also had an integer-typed one. The two live in different Zabbix history
  tables and the fetch stopped at whichever answered first, so half a printer's
  cartridges silently had no history at all.
- A shop-floor PC that reported the machine number of a machine ShopDB already
  knew got a 500 from the collector, on every report, forever. The reported
  machine number was being written to the PC's own `assets.assetnumber`, which
  is uniquely indexed and already held by the machine, so the insert failed with
  a duplicate-key error and the whole report was lost: operating system, boot
  time, applications, printers and access protocols never landed. The machine
  number now identifies the machine only. A new PC takes its hostname as its
  asset number, which is what the existing data already does, and an existing
  PC's asset number is never overwritten.
- Configuration backups piled up duplicate revisions on any machine number
  reported by more than one PC. A posted config was compared against the latest
  revision for the machine, so where several PCs share a number - the part
  markers do, and their configs differ by COM port - each PC's post differed
  from whichever PC posted last, nothing deduped, and the table grew by a row
  per PC per collection cycle. A backup chain is now per source PC, so an
  unchanged config is a no-op again and each PC keeps its own history against
  the machine it belongs to. Retention prunes each chain separately, so a busy
  PC's revisions can no longer push out a quiet PC's only backup, and the
  revision diff compares against the same PC's previous revision rather than
  another PC's.
- The backup collection interval could not actually be changed. The collecting
  script reads `backups_intervalhours` from the public settings endpoint before
  it holds any credential, but the plugin did not declare the key as public, so
  the endpoint never returned it and the script fell back to its built-in 24
  hours - silently. The setting looked configurable in the UI and was not.
- A backup export containing a value with an empty right-hand side (`Name=`)
  failed to parse, and with it the whole file, so that machine could never be
  backed up. The form is not strictly legal but occurs in real exports. It is
  now read as an empty string, preserving the value name.
- The 3D parts kiosk label prefix never appeared on the kiosk. The kiosk runs
  logged out, and an unauthenticated read of a setting is limited to an
  allowlist the key was not on, so the kiosk got a 404 and fell back to no
  prefix. An admin previewing the same page while logged in saw it, which is
  why it looked like it worked. A plugin now declares which of its settings a
  logged-out page may read.
- The same setting also looked like it would not save. The row did not exist on
  a site that installed the plugin before the setting was added, so the first
  save created it - under a placeholder category, where the settings page, which
  lists by category, could no longer see it. The value was in the database the
  whole time. A first-time save now files the key under the category its owner
  declares. Any row already misfiled is repaired in place on upgrade, value
  untouched. The 3D parts alert email and the employee directory host were in
  that state.
- Settings added in a later plugin version never reached a site that installed
  an earlier one. The seeding ran from `on_install` / `on_enable`, which fire
  only on a state transition, so neither ran again on an upgrade - and the
  comment claiming enable ran every upgrade cycle was wrong.
  `flask plugin upgrade-all` now seeds missing declared settings, without
  touching values a site has configured.

### Added

- A PC reporting a machine number is now linked to that machine. Until now the
  number was collected and then discarded, so a replaced PC never took over its
  bay: the retired PC kept the link and everything that walks PC to machine, the
  warranty machine column and the DNC info card among them, pointed at hardware
  that had been pulled out. The link is a `controls` relationship carrying a
  `collector:machine` origin label, so a link made by hand is never touched.
  Superseding a PC archives the old link rather than deleting it, keeping the
  history of which PC ran a machine and when. The retired PC's status is
  deliberately left alone: the collector cannot tell whether it was shelved,
  sent for repair or re-imaged for another bay, so a person decides.

  A second PC reporting a machine another PC already holds is treated as a
  claim, not a handover. A PC imaged for a machine carries that number before
  it ever reaches the floor, so the PC actually running the machine keeps the
  link while it is still reporting and still In Use, and the challenger is
  recorded as a dormant link. The handover completes on its own once the old PC
  has been quiet for a day, or immediately when someone moves it off In Use.
  Without this the two PCs traded the link back and forth on every collector
  cycle.

  Alerts for both cases go out by email and webhook, gated on a new
  `computers_machinelink_alerts` setting that ships OFF: a site may legitimately
  run several PCs on one machine number, and there the alerts fire on correct
  data. The collector response warns either way.
- A PC that drives a subordinate device is now declared, not coded. Two cases
  had arrived separately and been written twice: a metrology PC drives a CMM or
  Keyence unit that becomes a measuring-tool asset, and a part-marker PC drives
  a marker that becomes a machine asset filed under its operation. Both mint a
  device, link the PC with `controls` and archive that link on a re-image - one
  mechanism with different nouns. They are now one map, and a site can add or
  retarget an entry through a `subordinatedevice_<pctype>` setting, so the next
  case - two marking lasers or two wax-trace units on one machine number - is
  configuration rather than a code change. A malformed override falls back to
  the default instead of failing the collector push.
- `flask relationships check-shared-machines` reports machine numbers claimed
  by more than one PC, and says which are modelled and which are faults. Several
  devices genuinely sharing a number and two PCs mis-numbered at imaging look
  identical from the outside; the difference is whether anything is filed under
  the operation, which is what the check reports. Read-only.
- A part marker is its own asset. Several Telesis markers can serve one
  operation number, so treating the operation as the marker collapsed separate
  devices into one record: their configurations, which differ by COM port,
  overwrote each other in the backup history, and no question about an
  individual marker - how many there are, which port one is on, which one
  failed - could be asked at all. There is one marker per PC, so the collector
  now mints a Part Marker machine asset for a marker PC the same way it already
  mints a CMM or a Keyence unit for a metrology PC: the PC `controls` the
  marker, and the marker is `partof` the operation it serves. Because `controls`
  propagates through `partof`, control of the operation still follows from
  controlling its marker, and two markers on one operation no longer contest a
  link only one of them can hold. Backups from a marker PC file against the
  marker. Moving a marker to another operation archives the old membership
  rather than deleting it.
- Plugin contract 0.16.0: `get_settings_defaults()` lets a plugin declare the
  settings it owns (key, default, type, category, description, and whether an
  unauthenticated caller may read it). See `docs/PLUGIN-HOOKS.md`.

## [0.8.1] - 2026-08-05

Everything here shipped after v0.8.0 was tagged the same morning, driven by two
sites putting real data in for the first time. Most of it is defects that only
appear when somebody who did not build the software tries to use it.

### Fixed

- A blank optional code could be saved once and never again. A unique nullable
  column accepts any number of NULLs but exactly one empty string, so the second
  business unit with no code collided with the first and returned 500 from a
  field the form correctly showed as optional. Blank now stores as NULL, which
  covers every unique nullable column - asset numbers, hostnames, item codes,
  subnet names, gage-lab tags - not just the one that was reported. A genuine
  duplicate answers 409 with a readable message instead of a bare 500.
- A lobby display with exactly two slides never changed between them. The
  slideshow was started twice, and two timer chains advancing one slide each
  landed back on the first every cycle. With three or more slides it advanced by
  two and merely skipped one, which is why it went unnoticed. The per-slide
  duration the feed has always sent was also ignored in favour of a hardcoded
  ten seconds.
- Clicking slightly outside a modal discarded a part-filled form, in 35 modals
  across 30 files. Confirmation dialogs still dismiss that way, since they hold
  nothing to lose.
- Selecting a filter while past page one returned an empty list, because the
  filter asked for page five of a result set that now had one page.
- Model photos could not be saved: an uploaded photo sets the field to an
  application path, and the input was type="url", which demands an absolute
  address. The upload button was also hidden when creating a model.
- An empty bordered box appeared after Notes on the network device form.

### Added

- The equipment catalog travels. `flask seed catalog` loads 53 vendors, 128
  models with photos, 146 printer supply part numbers and every type vocabulary,
  so a new site can start adding printers immediately instead of retyping a
  catalog another site spent a year building. Idempotent and additive; catalog
  only, with nothing site-specific. Offered by the installer as a tick-box and
  by the operator console.
- `shopdb-admin.ps1 repair` completes an interrupted provisioning, and `check`
  now says when a server is not fully provisioned instead of leaving it to be
  inferred from 500s on unrelated pages.
- Network devices can be linked to a catalog model, and their map position is
  picked on the floor plan rather than typed as coordinates.
- An asset's vendor and type can be derived from its catalog model, by exact
  name match only, with a reviewable backfill script.

### Changed

- Labels say whose type they mean: Machine Type, PC Type, Printer Type, Device
  Type, next to the catalog's Model type.
- The 3D parts kiosk label prefix is a setting rather than one site's initials
  hardcoded in the source.
- The operator console menu is grouped by what each action touches.

## [0.8.0] - 2026-08-05

First release to carry the Windows installer. Everything below shipped after
v0.7.0 was tagged, and a complete install was exercised end to end on Windows
Server 2019 before this release was cut.

### Fixed - installer, from a real Server 2019 install

- `packaging` was imported by the plugin loader but declared nowhere. It reached
  development and CI only as a dependency of pytest, so the whole suite passed
  while a virtual environment built from `requirements.txt` alone - which is
  exactly what the installer builds - could not import the application at all.
  `tests/test_runtime_dependencies.py` now fails on any runtime import that is
  not a declared dependency.
- IIS returned 500.52 before the application was ever launched. `web.config`
  declared `<allowedServerVariables>` for the X-Forwarded-For rule, and that
  section ships `overrideModeDefault="Deny"`, so the whole file was rejected.
  The installer now permits the single variable at server level instead of
  unlocking the section for every site on the machine, and repairs a
  `web.config` an earlier build had made unusable.
- The config unlock ran before the application it unlocks existed, so the scoped
  form could never succeed on a first install and every install silently fell
  back to granting handler delegation server-wide.
- The stage 5 smoke test discarded the status code and error page it had already
  received, reporting "site did not return 200" for a fault IIS had named. It
  now records both, plus the tail of the application log.
- Database dumps were readable by every authenticated user: a directory created
  under ProgramData inherits `Users:RX`, and the owner-only ACL was applied only
  when the installer itself created it.
- The uninstaller ran the 32-bit PowerShell, which cannot see IIS, so the site,
  pool and application survived a "successful" uninstall pointing at a deleted
  directory.
- Uninstall matched applications by alias alone and would remove an unrelated
  application of the same name under another site.
- Wizard input reached a command line unchecked: unvalidated ports, a drive-root
  path that escaped its own quote, and a password written as ANSI but read back
  as UTF-8, which reported a correct non-ASCII password as wrong.
- Plugin deregistration could never succeed - it omitted `--yes` against a
  command that prompts - while the plugin directory was deleted regardless.
- Preflight rows were drawn past the bottom of the panel and silently vanished;
  the failures loop had no cap at all.

### Changed - installer behaviour

- "Is this a re-run of my install?" is answered from a durable install record
  rather than inferred from the state of the machine. Nothing on a server says
  who created its database tables, so a retry after a failed first install was
  taken for an upgrade of a working system: it demanded a mandatory backup of a
  database its own failed attempt had written, then refused to prune tables it
  had created minutes earlier. During unfinished first provisioning the backup
  is advisory and prune may force; on an established install both are unchanged.
- The installer offers every bundled plugin, so one build serves any site
  instead of one build per plugin profile.

### Added - operator documentation and diagnostics

- `docs/UPDATES-WINDOWS.md` - what operators should expect from future updates,
  bug fixes and security releases, including downtime, what is preserved, and
  the effect on other sites sharing the same IIS server.
- `docs/RELEASING-WINDOWS.md` - how to build and release, and the two known gaps.
- `deploy/windows/shopdb-diagnose.py` - collects what IIS answers, the config
  lock state, the application logs and the ACLs in one pass, scrubbing secrets
  before writing anything.

### Added - the installer itself

- Air-gapped Windows installer (`deploy/windows/installer/`). One `.exe` per
  site, built from that site's plugin profile, containing Python, the wheels,
  the SPA, the IIS modules and optionally MySQL. Operator docs:
  `docs/INSTALL-WINDOWS.md` and `docs/OPERATE-WINDOWS.md`, both shipped onto the
  server. `docs/INSTALL-WINDOWS-IIS.md` and `docs/DEPLOY-WINDOWS-IIS.md` are now
  reference-only, for hand-built servers.
- `bundle-lock.json`: an exact sha256 + size record of the installer's
  third-party payload (wheels, Python installer, IIS MSIs). Verified as set
  equality by both builders and again on the server before anything runs; there
  is no install-time override. Regenerate with `refresh-bundle-lock.ps1`.
- CycloneDX SBOM (`sbom.cdx.json`) generated on every build from
  `requirements.txt` and `package-lock.json`, covering both ecosystems, staged
  into the application tree so an air-gapped server can answer "do we carry this
  component" locally: `shopdb-admin.ps1 verify -Path <name>`.
- `shopdb-admin.ps1`, the operator console: status, start/stop/restart, logs,
  health check, backup, plugins, verify. `check -Json` emits secret-free
  structured state for pasting into a support ticket or an AI assistant.
- `build-installer.ps1`, the whole build natively on Windows, so a work PC needs
  no Bash. Shares `scripts/resolve_plugin_closure.py` with `build-site.sh`.
- URL Rewrite is bundled and the wizard asks where client IPs come from
  (`-ClientIpSource direct|proxy`). Without the rule IIS sends no
  `X-Forwarded-For` at all and every client reads as 127.0.0.1.

### Changed

- `requirements.txt` and `requirements-dev.txt` are compiled `--universal
  --generate-hashes`. Installs run under `pip --require-hashes`, so a wheel whose
  sha256 is not listed is refused. The dev lockfile is constrained to the
  production pins; the two had drifted.
- Python 3.14 across the Dockerfile, CI, `web.config` and the docs, which
  previously declared four different versions.
- The naming/style gate covers Markdown, JSON and YAML, not just code.

### Fixed

- `plugins/employees` imported `shopdb.core.models` directly, failing the
  contract-surface test on `main` since the dashboard employee-name resolver
  landed.
- `scripts/build-site.sh` copied all of `deploy/` into its own output directory,
  which recursed when the output was staged inside it - the documented Windows
  build could not complete.
- Plugin baseline migrations inherited the MySQL server's default charset: the
  utf8mb4 compiler hook lived in `migrations/env.py` and so covered the core
  chain only. It is now `shopdb/utils/mysql_charset.py`, imported by both.
  `flask db-utils preflight` reports the database's default charset.

### Added

- Configurable site timezone: a `site_timezone` site setting (default
  `America/New_York`, public-readable), editable in Settings > Site >
  Localization. Notification start/end times are entered and displayed in this
  zone, and daily-reset notification expiry is computed in it. A shared
  `frontend/src/utils/datetime.js` (Intl-based, DST-safe) does the conversion.

### Changed

- Asset detail pages (machines, PCs, printers, network devices, measuring
  tools) now share one canonical card skeleton: Identity -> type-specific ->
  status -> Location & Organization -> domain -> Custom Fields -> Warranty ->
  Relationships -> Notes -> audit footer. The location card reads "Location &
  Organization" on every page. The network device page was rebuilt into the
  family (its "Asset Information" folded into Identity, "Record Info" converted
  to the standard audit footer). Printer Notes moved out of mid-page to
  second-to-last and the printer gained an audit footer. Template reordering
  only; no data or API changes.

### Fixed

- Notification start/end times were off by the timezone offset (a 2:34 PM entry
  displayed as 6:34 PM). Times are now stored UTC and shown/entered in the site
  timezone; the calendar keys all-day events off the site-local day.
- Kiosk displays showed a white screen on login: a legacy 32-bit kiosk
  installer's autostart kept relaunching Edge at a now-dead URL. The install's
  HKLM Run value was WOW64-redirected into `SOFTWARE\Wow6432Node\...\Run` and
  survived earlier cleanup. The `gea-shopfloor-display` dispatcher now purges
  the legacy autostarts every enforce cycle across both registry views, all
  user hives, Run/RunOnce/policy-Run, and every Startup folder.
- List pages keep the current page (and search term) in the URL query, so
  paging to page 9, opening an item, and hitting browser Back returns to page 9
  instead of resetting to page 1. Applies to all 18 list views via a shared
  useListQuery composable; page 1 with no search stays a bare path.
- PC detail Installed Applications no longer 500s and silently disappears on
  real PCs (ComputerInstalledApp had no to_dict); the section renders app
  name, version, and description again.
- Employee detail skips its USB panels when the usb plugin is disabled (no
  more 404 console noise).
- Shopfloor kiosk header text is readable (light on the dark navy header).


### Added (continued)

- Collector-driven PC -> printer relationships. The computers collector schema
  gained optional `defaultprinter` (string) and `printers` (array of strings)
  fields carrying Win32_Printer identifiers. On ingest each identifier resolves
  to a printer asset (by windows name / share / hostname / asset number-name or
  a communications IP) and the PC is linked to it: the default via a
  `defaultprinter` relationship, the rest via `connectedto`. The links render in
  the shared Relationships card on both the PC and printer detail pages. The
  sync is idempotent and archives collector-created links to printers no longer
  reported (tagged `assetrelationships.label = 'collector:printers'`, so
  manually-created links are never touched); unresolved identifiers become
  response warnings, never failures. The collector response carries
  `printerlinkcount` and a `printerlinks` list. See docs/COLLECTOR-INTEGRATION.md.
- Searchable custom fields. Each custom-field definition gained a `searchable`
  flag (Settings > Custom Fields). When on, that field's stored values are
  matched by global search and a hit routes to the owning asset's detail page.
  The asset's `search_<type>_enabled` domain toggle still applies, and matches
  dedupe against built-in-field asset hits so an asset appears once. Inactive or
  non-searchable fields are never matched.
- Single-label sheet-position printing. The single asset-label page
  (`/print/asset-label/:assettype/:id`) gained an Output control that toggles
  between the standalone label (unchanged default) and placing that one label at
  a chosen cell (1-6, via a 2x3 grid picker) of a ULINE 6-up sheet, leaving the
  other five cells blank. This prints a single label onto the correct physical
  spot of a partially-used sheet instead of wasting a fresh sheet, mirroring the
  legacy shopdb behavior and complementing the batch page's start-cell offset.
  The ULINE 6-up cell layout and dimensions are replicated from
  `AssetLabelBatch.vue` (left untouched); encode resolution stays shared via
  `assetLabel.js`.

## [0.7.0] - 2026-07-12

### Added

- Email sending. A stdlib-only mail service (`shopdb/utils/mailer.py`;
  `smtplib`/`ssl`/`email`) reads the existing `email` SMTP settings
  settings-first with an `SMTP_*` env fallback, sends multipart HTML+text, and
  is a graceful no-op (logs a warning, returns False) when email is disabled or
  the host is unset. The SMTP password is never logged. Three flows use it:
  (1) New-user welcome + forced first-login password change. Admin-created users
  (POST `/api/users`) are flagged `mustchangepassword` (new `users` column,
  migration `7d23_user_mustchangepassword`, default false) and sent a
  best-effort welcome email with the facility name, username, temporary
  password, and sign-in link; the account is created even if mail fails
  (response carries a `warning`). Login returns `mustchangepassword`; the
  frontend forces the user through a new `/change-password` view (POST
  `/api/auth/change-password`, jwt-guarded) before the app, and changing the
  password clears the flag and resets lockout counters. A self-service "Change
  password" entry is also available from the user menu.
  (2) Test email. POST `/api/settings/test-email` (settings.edit) sends a probe
  and surfaces any SMTP error with the password scrubbed; wires up the Email /
  SMTP settings page "Send Test Email" button.
  (3) On-demand alert/report delivery. POST `/api/reports/email`
  (reports.export) mails `{subject, columns, rows}` as an HTML table to a
  supplied recipient or the site `alert_recipients`; an "Email report" button on
  the Warranty and Toner report pages posts the loaded rows. There is no
  scheduler: automation is an external cron hitting the endpoint with a scoped
  API token (PAT). Documented in `docs/CONFIG.md`.
- Shared asset label/code generator: a single `/print/asset-label/<assettype>/<id>`
  page (public, like the other `/print/*` routes) that any asset detail page
  opens via a "Print Label" button (machines, computers, printers, network
  devices, measuring tools). A no-print controls panel toggles the layout
  (`card` badge vs `plain` code-only), the code type (QR vs CODE128 barcode),
  and what the code encodes: the asset page link, asset number, serial number,
  a per-type custom target template, or - for measuring tools by default - the
  tool's inspection location code so every tool at one operation shares one
  code (e.g. `0615`). QR codes reuse the shared logo-overlay renderer. New
  `printing` settings seed and surface on the Printing & Labels settings page:
  `qr_target_machine`, `qr_target_computer`, `qr_target_network_device`,
  `qr_target_measuring_tool`; `label_default_style` (default `card`) and
  `label_default_codetype` (default `qr`); and a per-asset-type default for what
  the code encodes, `label_default_encodes_<type>` (machines default to their
  machine number, measuring tools to their inspection location code, the rest to
  a page link), all overridable on the label page itself. When the chosen field
  has no value (e.g. serial number on an asset with none), the label states so
  instead of rendering an empty code. Asset payloads now carry a derived
  `locationcode` (leading token of the resolved own/inherited location name).
- Batch label sheets: a "Print Labels" button on each asset list page opens
  `/print/asset-label-batch/<assettype>`, a multi-select sheet that lays the
  chosen assets onto ULINE label pages (6-up 3 in x 3 in, or a dense 72-up
  mini-label format), with a start-cell offset to reuse partial sheets. Shares
  the same code-type and encode settings/defaults as the single label, so a
  batch of measuring tools encodes each tool's inspection location code just
  like the single label does. Restores the ULINE batch printing the legacy
  shopdb had, generalized across all asset types.
- Support-team contact UX: the settings Support Teams page now manages each
  team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus
  Add/Edit/Delete) instead of an inline row expander, and the application
  detail Support card renders Email (`mailto:`) and Microsoft Teams chat
  (`teams.microsoft.com/l/chat`) action buttons for every contact that has an
  SSO. Both link targets derive as `sso@<domain>` from a new `site` setting
  `contact_email_domain` (default `geaerospace.com`; blank hides the buttons),
  surfaced in Site & Facility settings under Naming & Patterns and read on the
  frontend via `getContactEmailDomain()`.
- Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC
  permissions its own routes enforce, instead of core accumulating every
  plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The
  core catalog (`Permission.CORE_PERMISSIONS`) now holds only genuinely core
  sets (assets, applications, reports, settings, users, audit, apitokens,
  collector); the 36 permissions for machines, computers, printers, network,
  knowledgebase, notifications, usb, warranty, and measuringtools moved into
  each owning plugin's hook. New core helper `full_permission_catalog()` merges
  core plus every ENABLED plugin's permissions and backs all three consumers:
  `flask seed permissions`, the role grid (`GET /api/users/permissions`), and
  API-token scope validation (`ApiToken.unknown_scope_names`). Plugin install
  and enable seed the plugin's own permissions idempotently. A disabled plugin
  drops out of the catalog (no new scope grants or role assignments), but its
  existing `Permission` rows and role links persist so current roles keep
  working. Docs: `docs/PLUGIN-HOOKS.md` new section, `docs/PLUGIN-GUIDE.md`
  permissions walkthrough rewritten to the hook, `docs/PLUGIN-QUICKSTART.md`
  hooks table row.
- Personal API tokens (PATs) so scripts and integrations authenticate without
  the hourly-expiring login JWT (immediate consumer: long legacy-import runs
  that die when the JWT expires mid-run). New core `apitokens` table + migration
  `7d21_apitokens` (stores only the sha256 hash of each secret; the full secret
  `shopdb_pat_<40 hex>` is shown ONCE at creation). New core blueprint
  `/api/apitokens` (list own / admin `?all=true`; create; rename or deactivate;
  revoke). A `Bearer shopdb_pat_...` header is recognized before any JWT decode
  by a before_request shim that mints a request-scoped JWT for the token's
  owner, so the entire existing auth+authz stack (jwt_required,
  require_permission, require_role, import mode, current_user) authenticates the
  PAT as its owner with zero decorator changes; an invalid, revoked, or expired
  PAT gets a clean 401. `lastusedat` is stamped on use (throttled to at most one
  write per 60s). Any authenticated user manages their own tokens; admins may
  list or revoke anyone's. New Settings > API Tokens page (`ApiTokensList.vue`)
  with a create modal that reveals the secret once (copy button) and an admin
  All Tokens section. Docs: `docs/IMPORT-API.md` and `docs/CONFIG.md` updated to
  recommend a PAT for imports. Core feature; no plugin-contract change.
- Optional permission scopes on personal API tokens. A token MAY carry a scopes
  list (permission names, migration `7d22_apitokens_scopes` adds the nullable
  `apitokens.scopes` JSON column); NULL keeps the original behavior (acts as its
  owner). A scoped token grants ONLY the listed permissions, intersected with
  what the owner actually holds at use time, and SUSPENDS the admin-role bypass,
  so a scoped token minted by an admin is genuinely limited: it is denied on
  role-gated (`require_role`) endpoints and gets no import mode. The shim mints
  the request JWT with a `patscopes` claim that `require_permission`,
  `require_role`, and `import_mode_active` read; normal login JWTs carry no such
  claim and are unaffected (zero regression). Scopes are validated at write time
  against the token OWNER's permissions (the scope ceiling - a token can never
  grant more than its owner holds; when an admin edits another user's token the
  ceiling is that owner's permissions), rejecting unknown or unheld names 400.
  Minting/managing tokens now requires the new `apitokens.create` permission
  (category `apitokens`; admins hold it by default, grantable via the roles UI)
  rather than being open to any authenticated user. The Settings > API Tokens
  create/edit modals gain a "Restrict permissions" section (a category-grouped
  checkbox grid limited to the permissions the creator holds) and the token
  lists show a full-access / N-permissions access chip.
- Managed collector service tokens: the collector ingest API
  (`/api/collector/<plugin>` + the legacy `/pc` `/apps` `/heartbeat` `/bulk`
  `/status` endpoints) now ALSO accepts a managed API token scoped to the new
  `collector.ingest` permission (category `collector`), on top of the existing
  `COLLECTOR_API_KEY[_<PLUGIN>]` env keys (which stay supported as a
  bootstrap/legacy fallback - nothing breaks). The token may be presented in
  `X-API-Key` (as GE-Enforce sends today) OR as an `Authorization: Bearer`
  token; both transports validate the PAT the same way the login shim does
  (hash lookup, active, unexpired, active owner) via a shared
  `resolve_api_token` helper refactored out of `apitoken_auth.py`, require
  `collector.ingest` in the token's scope list AND that the owner holds it, and
  stamp `lastusedat` (same 60s throttle). A token scoped to ONLY
  `collector.ingest` is a collector service token: it authorizes the collector
  API and NOTHING else - the existing scoped-token machinery denies it on every
  permission- and role-gated route and on import mode, so a leaked collector
  token cannot touch the regular API. Recommended flow (documented): an admin
  mints the scoped token (the scope suspends the admin bypass, containing it);
  rotate by minting a new one, deploying via `site-config.json`, watching
  `lastusedat`, then revoking the old. The Settings > API Tokens create modal
  gains a "Collector service token" quick-preset (pre-selects only
  `collector.ingest`). Docs: `docs/COLLECTOR-INTEGRATION.md` (new "Managed
  collector tokens" section) and `docs/CONFIG.md`. Core feature; no
  plugin-contract change.
- Vendor-model photos on asset detail heroes: computers and printers now
  surface the linked model's `imageurl` in their extension payloads (the
  field machines already exposed), and the machine, PC, printer, network
  device, and measuring tool detail pages render the photo in the hero card
  when present (hidden cleanly when absent). Network devices and measuring
  tools have no model link yet, so their heroes stay photo-less until one
  is added.
- Dualpath "single machine" site toggle (`dualpath_single_machine`, default
  on). A Dualpath relationship pair is one physical dual-bay machine (single
  controller, bay-selector switch); when on, the machines list, dashboard and
  machines-by-type counts, and the floor map collapse each pair to one entry
  (the lower natural-sort assetnumber is PRIMARY; the SECONDARY bay is hidden)
  and show a combined `2007 / 2008` label. The data model is unchanged (both
  bay records always exist); detail pages stay per-bay and always show a
  sibling-bay banner regardless of the toggle. Contract surface (plugin
  contract bumped 0.8.0 -> 0.9.0, additive): new `shopdb.api` helpers
  `resolve_dualpath_pairs` and `dualpath_single_machine_enabled`, consumed by
  the machines plugin to collapse pairs contract-purely.
- Relationship propagation, wired and data-driven: relationship types
  declare propagation-through pairs (relationshiptypepropagations M:N,
  replacing the never-consumed single column); creating a controls link on
  one Dualpath bay auto-creates it on the partner bay, and
  `flask relationships propagate` backfills existing data.
- Employee photos, mode-aware: self-hosted directory employees support
  upload/replace/delete (admin), served publicly for kiosk cards; external
  directory mode passes the HR-supplied picture URL through read-only. One
  resolver feeds the shopfloor recognition/recert cards and the employee
  detail hero in either mode.
- Vendor-model photo management. New admin-gated core endpoints
  `POST /api/models/<modelid>/image` (multipart `file`, png/jpg/jpeg/gif/webp/svg,
  one image per model, replace semantics) and
  `DELETE /api/models/<modelid>/image`, plus the public
  `GET /api/models/image/<filename>` serve route. Uploads land in
  `instance/modelimages/` (survives upgrades, backed up with the rest of
  `instance/`) and set `models.imageurl` to the served URL; the manual Image URL
  field still accepts external URLs and the shipped `/images/models/*` assets
  (upload is additive). Delete only removes files we own under the instance dir.
  The Models settings page grows a thumbnail, Upload/Replace, and Remove
  controls in the edit modal. Asset hero images (e.g. the machine badge) read
  `imageurl` unchanged, so uploaded photos render with no consumer changes.

- Application support teams with contacts, replacing the legacy
  supportteams/appowners pair. New core `supportteamcontacts` table (multiple
  named contacts per team, ordered by `sortorder`); `supportteams` keeps
  `teamname` (now unique) and `teamurl` (a ServiceNow group deep link) and
  sheds the single-owner `appownerid` FK. New core blueprint at
  `/api/supportteams` (team + nested contact CRUD, admin-gated; `?teamname`
  exact-match lookup for import; delete a team 409s while any application still
  references it). Migration `7d18_supportteamcontacts` migrates each legacy
  team's app owner into one contact. Application payloads now flatten
  `supportteamname`, `teamurl`, and the team's active `contacts`; a Support
  card on the application detail page and a new `settings/supportteams`
  management page render them.

- Import mode: a complete, idempotent HTTP migration surface so a migration script
  can import the classic ASP shopdb through the API alone (no direct DB writes).
  - Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new
    `shopdb.api` helpers `apply_import_timestamps`, `import_mode_active`,
    `parse_import_datetime` in `shopdb/utils/import_mode.py`. When the caller is
    an admin AND sends header `X-Import-Mode: true`, create/update endpoints
    accept optional `createddate` / `modifieddate` (ISO or legacy
    `YYYY-MM-DD HH:MM:SS`, naive-UTC) and preserve them instead of stamping now.
    Non-admin or missing header: the fields are ignored exactly as before.
    Wired into every timestamped import target: assets (all five type plugins),
    vendors, models, modeltypes, businessunits, locations, operating systems,
    applications, knowledge base, USB devices, and asset relationships.
  - Natural-key exact-match lookup filters for the documented
    lookup-then-upsert idempotency recipe: `assetnumber` on all five asset
    plugin list endpoints; `vendor`, `modelnumber`, `modeltype`,
    `businessunit`, `locationname`, `osname`/`osversion`, `appname`,
    knowledge base `linkurl`/`shortdescription`, warranty `servicetag`/`vendor`,
    and notification `ticketnumber`.
  - Backdated event history: in import mode the selfhosted USB checkout/checkin
    endpoints accept optional `checkouttime` / `checkintime` overrides so
    migrated `usbcheckouts` rows keep their real event times.
  - New operator manual `docs/IMPORT-API.md` grounded in the real `prodscratch`
    legacy schema: order of operations, a full table-by-table mapping, honest
    no-target list with dispositions, a worked idempotent Python importer, and
    row-count parity checks.

### Changed

- System Settings is no longer one tabbed page. The inner tab bar is gone and
  each section is its own routed settings page reached through the settings
  rail: ServiceNow, Zabbix Supplies, Dell Warranty, and Collector PC Types
  (the former Integrations dumping ground, now split three-plus ways), plus
  Branding, Floor Map, Printing & Labels, Email / SMTP, Authentication,
  Audit & Logging, Asset Identifiers, and Global Search. The rail regroups
  these under Site & Facility, Integrations, Communication, Search & Identity,
  and Access & Security. Shared load/save/upload plumbing moved into a
  `useSystemSettings` composable so the pages stay thin. Old bookmarks keep
  working: `/settings/system` and every `/settings/system?tab=<key>` redirect
  to the matching new page.

### Fixed

- Audit log: hovering a user's SSO now shows their full name (best-effort,
  resolved from the employee directory in either mode).
- Refreshed the internal status docs to match the code (project active
  state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0),
  corrected the get_asset_panels endpoint path in the hook docstring, and
  removed leftover debug console.log lines.
- Measuring tools wired into the remaining cross-cutting surfaces an audit
  found them missing from: generic asset serialization (typedata + pluginid,
  which also fixes relationship-card links to tools), map subtype
  filtering/coloring and the MapEditor filter, dashboard totals, warranty
  asset links (via a new by-asset detail route), and the two ADR-010 hook
  declarations (presentation route token corrected; the calibration
  map-overlay endpoint now actually exists). The login avatar also resolves
  through the employee-photo helper, so self-hosted uploads show.
- Measuring tools are now wired into the asset-identifier matrix and global
  search. The Settings identifier matrix gains a Measuring Tool column and the
  gauge-lab and maintenance reference inputs/rows on the measuring-tool form
  and detail pages honor those per-type toggles (a maintenance-reference field
  was added, matching the other asset types). Global search gains a Measuring
  Tools domain toggle and filter chip, routes measuring-tool hits to
  `/measuringtools/<id>` (previously the generic `/assets/<id>` fallback), and
  matches on `gaugelabreference` so a gage-tag lookup resolves the tool.
- Site & Facility settings page renders booleans as toggles and the
  directory-mode settings as dropdowns, with labels and help text for every
  field (no more raw keys or type-true/false boxes).
- System Settings tabs follow the URL: clicking a settings-rail link that
  only changes the ?tab= query (Branding, Floor Map) now switches the right
  panel, tab clicks update the URL, and browser back/forward restore tabs.
- Following a relationship link between two assets of the same type now loads
  the destination page instead of stale content (router-view keyed on path;
  query-only URL changes still avoid a remount).
- Asset relationships card no longer lists a symmetric peer twice. Relationship
  types gain `relationshiptypes.isdirectional` (migration
  `7d19_relationshiptype_directional`; seeded false for the connection-like
  types Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet,
  USB, WiFi, true for controls/Controlled By/Backup For/Master-Slave/partof/
  defaultprinter). The card now collapses every stored direction row of a
  symmetric type into one direction-blind "Connected" entry per peer (deleting
  it removes all collapsed rows), while directional types drop the
  Outgoing/Incoming headers for inline `Type -> peer` / `<- Type from peer`
  phrasing. The type CRUD and the per-asset relationships endpoint carry
  `isdirectional`; the Relationship Types settings page gains a Directional
  toggle.

## [0.6.0] - 2026-07-11

### Added

- measuringtools plugin (ADR-005): gage-lab instruments as Asset extensions
  with type lookup (color-coded), calibration tracking (derived
  overdue/due-soon/current status), calibration report, and full frontend.
  Built as the framework exemplar; docs/PLUGIN-GUIDE.md walks through its
  construction step by step as the plugin-system tutorial.
- CSV export on the Warranty and Toner report pages; per-report filter
  controls (business unit, asset type, location, application, limit) on the
  inline core reports; report open-state is URL-backed and deep-linkable.
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin now
  carries its own chain with a stamp-only anchor; new plugin schema changes
  land in `plugins/<name>/migrations/`, never the core chain. Deploys run
  `flask plugin upgrade-all` after `flask db upgrade`.
- Frontend plugin route gating (ADR-009): a disabled backend plugin's pages
  redirect to the dashboard; new public `GET /api/plugins/enabled`.
- `get_reports()` plugin hook (plugin contract 0.5.0 -> 0.6.0): plugins
  contribute their own report cards; warranty and toner cards moved off the
  hardcoded frontend list.
- Reports hub grouped by category with a search filter.
- Configurable QR label targets: `qr_target_printer` / `qr_target_usb`
  settings (blank = the asset's own page, else a URL template with
  placeholders) and a `usb_label_style` barcode/QR toggle for USB
  mini-labels. New Settings > Printing & Labels section.
- Site palette theming: optional `brand_primary_dark_color`,
  `brand_accent_color`, `brand_sidebar_color` settings applied at bootstrap.
- Collector integration guide rewrite: header-only auth reference and a
  paste-ready GE-Enforce PowerShell reporting function.

### Changed

- Equipment -> machines rename (backend). The equipment plugin is now the
  machines plugin: `/api/equipment` -> `/api/machines`, tables
  `equipment`/`equipmenttypes` -> `machines`/`machinetypes` (columns
  `equipmentid` -> `machineid`, `equipmenttypeid` -> `machinetypeid`,
  `equipmenttype` -> `machinetype`), permissions `equipment.*` ->
  `machines.*`, assettype value `equipment` -> `machine`. The legacy core
  `machinetypes` lookup (it types the vendor MODELS catalog, not machine
  instances) is renamed to `modeltypes` (`/api/machinetypes` ->
  `/api/modeltypes`, `models.machinetypeid` -> `models.modeltypeid`) to
  free the name. Data flips migrate assettypes, auditlog entitytype,
  settings keys (`identifier_*_equipment_enabled` ->
  `identifier_*_machine_enabled`, `search_equipment_enabled` ->
  `search_machine_enabled`), and permission rows in place; plugins.json
  registry entries carry over automatically. Upgrade: run
  `flask db upgrade` then `flask plugin upgrade-all`.

- Inter (variable) replaces Roboto, bundled locally - no Google Fonts
  fetch, so air-gapped installs render correctly. Tables use tabular
  numerals.
- ServiceNow defaults point at the current geaerospaceqa.service-now.com
  global search (search, incident, and change links).

### Fixed

- USB frontend remapped to the actual API shape (`device_id` /
  `device_desc`): device list, detail, form, label batch, and the employee
  profile's checked-out/history panels were all reading dead legacy fields.
- External-mode `GET /api/usb/checkouts/active` now honors the `badge`
  filter.
- Warranties list page no longer demands login (matches every other list
  page; reads were already public).
- Removed the dead legacy Warranty Status report (always-zero buckets from
  a retired column); the warranty plugin's report is the real one.
- Pruned dead usbApi client methods that had no backend routes.

## [0.5.0] - 2026-07-10

First release cut with a version, tag, changelog, and CI. Focused on
letting other GE Aerospace sites stand up their own self-hosted instance
(single-tenant per ADR-004).

### Added

- First-run setup wizard (`/setup`): creates the initial superadmin
  in-app, configures each plugin (create tables here vs connect your own
  database), uploads light/dark floor-map blueprints, and seeds starter
  reference data.
- Self-hosted employee directory and USB plugins: in-app management plus
  CSV import, no external database required. Both ship default-disabled
  with an enable-time provisioning note.
- Dell warranty plugin: real Dell provider, bulk warranty sync,
  add-warranty from asset pages, PC hero warranty badge, disk-cached Dell
  API token.
- Custom fields, and a two-pane settings shell with tabbed, searchable
  System Settings and Settings index pages.
- Dashboard defaults (visitor-IP to business-unit mapping) for kiosk
  displays; printer installer endpoint (data plus floor-map positions).
- Global toast notifications replacing `alert()` calls.
- Multi-stage Docker build that compiles the Vue frontend and ships
  `frontend/dist`, which Flask serves.
- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides;
  reconciled README, DEPLOY, status docs, and ROADMAP.
- ADR-007 (product versioning and releases), CHANGELOG, and best-effort
  Gitea Actions CI (backend tests, naming/style gate, frontend build).

### Changed

- Plugin contract (`__contract_version__`) settled at 0.5.0: full plugin
  import surface exposed via `shopdb.api`, dead search hook removed, and
  the dashboard-widgets hook wired to a real consumer.
- Role-based access control now enforced on write routes, including
  admin-only guards on dashboard-defaults writes.
- Branding, ServiceNow integration, employee-ID pattern, printer
  hostname template, and floor-plan blueprints are settings-driven and
  per-site configurable, with GE defaults preserved as shipped fallbacks
  (branding and floor-plan configurability landed in this release; some
  consumer wiring continues under Unreleased).

### Security

- Dashboard-defaults writes now require admin authorization instead of
  any authenticated user.
- Collector error responses no longer leak exception detail; failures are
  logged server-side with generic client-facing messages.
- Login rate limiting added (IP-based fixed window) on top of the
  existing account lockout.

### BREAKING

- Collector API key must now be sent in the `X-API-Key` header. The
  api-key-in-querystring fallback has been removed. Update any collector
  integration that passed the key as a query parameter. See
  `docs/COLLECTOR-INTEGRATION.md`.

[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.10.0...HEAD
[0.10.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.9.0...v0.10.0
[0.9.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.1...v0.9.0
[0.8.1]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.0...v0.8.1
[0.8.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...v0.8.0
[0.7.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.6.0...v0.7.0
[0.6.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...v0.6.0
[0.5.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/releases/tag/v0.5.0
