diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5d1d35..9d0575d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,202 +8,169 @@ so this log can be regenerated from history (e.g. with `git-cliff`).
## [Unreleased]
+## [0.9.0] - 2026-10-28
+
+Version info and release notes in the app, Word and ODT export, page
+duplication, skins that follow the system or the sun, and a large round of
+fixes to page order, sidebar drag and drop, and Markdown round trips.
+
### Upgrading
- Five database migrations run on upgrade (the Helm chart's migrate hook
- applies them).
-- Rolling back to 0.8.1 after the upgrade needs a database restore: 0.8.1
- doesn't start on a database that has migrations it doesn't know.
-- Everyone with an account sees the "knot was updated" card once after this
- upgrade. (#45)
-- The upgrade repairs the order keys of pages where two pages at one level
- shared a key, or a key was invalid or over 64 characters, keeping the
- order the sidebar showed. From then on no two top-level pages can share a
- key. (#52)
-- The migration that repairs the order keys locks the `documents` table until
- it commits, so on a large database, requests that touch pages wait while the
- pre-upgrade migrate job runs. (#52)
-- After the upgrade, a one-time background refresh renders the cached
- Markdown and the task listing of every page outside the archive again,
- about ten pages every two seconds, on one replica. Search, share links,
- zip export and My Tasks catch up as it runs. A healthy page's Markdown
- doesn't change, and its tasks keep their place in My Tasks. (#52)
-- During a rolling upgrade, pods still on the old version can answer
- `409 doc.sort_key_conflict` when someone creates or moves a page, until
- they are replaced. Don't run workspace imports while the rollout is in
- progress. (#52)
-- On S3, when the bucket refused to delete an archived page's files (for
- example because knot's credentials lack `s3:DeleteObject`), purging the
- page deleted it anyway and left its files in the bucket. Now the purge
- keeps the page and tries again on the next hourly run, so knot's S3
- credentials need `s3:DeleteObject`. Until a purge gets through, "Delete
- permanently" fails for that page and it can't be restored. (#52)
-- To see what the upgrade changes on your data before you roll it out, run
- the real-data compatibility check described in the Helm chart's README
- (Upgrading) against a restored backup or a staging copy. (#52)
+ applies them). Rolling back to 0.8.1 needs a database restore: 0.8.1
+ doesn't start on a database with migrations it doesn't know.
+- One migration repairs page order keys that were shared by two pages at one
+ level, invalid, or longer than 64 characters, keeping the order the sidebar
+ showed. It locks the `documents` table until it commits, so on a large
+ database, page requests wait while the migrate job runs. Afterwards no two
+ top-level pages can share a key. (#52)
+- After the upgrade, one replica renders the cached Markdown and task listing
+ of every page outside the archive again, about ten pages every two seconds.
+ Search, share links, zip export and My Tasks catch up as it runs. Healthy
+ pages don't change, and their tasks keep their place in My Tasks. (#52)
+- During a rolling upgrade, old pods can answer `409 doc.sort_key_conflict`
+ when someone creates or moves a page, until they are replaced. Don't run
+ workspace imports during the rollout. (#52)
+- On S3, knot's credentials now need `s3:DeleteObject`. When the bucket
+ refuses to delete an archived page's files, the purge keeps the page and
+ tries again on the next hourly run; it used to delete the page and leave its
+ files behind. Until it gets through, "Delete permanently" fails for that
+ page and it can't be restored. (#52)
+- Everyone with an account sees the "knot was updated" card once. (#45)
+- To see what the upgrade changes on your data first, run the real-data
+ compatibility check from the Helm chart's README (Upgrading) against a
+ restored backup or a staging copy. (#52)
### Added
-- The bottom of the sidebar shows the running version. It opens an About page
- with the version, build commit, release date and the release notes, newest
- first. `GET /api/release-notes`. (#45)
+- The sidebar shows the running version. It opens an About page with the
+ version, build commit, release date and release notes.
+ `GET /api/release-notes`. (#45)
- After an upgrade, a card names the new version and how many releases you
- missed, with a link to the release notes. Opening the notes or dismissing
- the card hides it until the next upgrade, on every device. New users start
- caught up, and development builds never show it. `GET /api/whats-new`,
- `POST /api/whats-new/seen`. (#45)
+ missed. Opening the notes or dismissing the card hides it on every device
+ until the next upgrade. New users start caught up; development builds never
+ show it. `GET /api/whats-new`, `POST /api/whats-new/seen`. (#45)
- Word (DOCX) and OpenDocument (ODT) export from the page header's download
menu, with diagrams as pictures. `GET /api/docs/{id}/export/diagrams`,
`POST /api/docs/{id}/export/{docx|odt}`. (#46)
-- Skins can switch on their own. In Settings → Appearance, choose Follow
- system to use your light skin while the operating system is in light mode
- and your dark skin while it is in dark mode, or Follow the sun to use the
- light skin from sunrise to sunset. knot works out where you are from your
- time zone, or from a location you type in. Nothing leaves the browser, and
- the choice is saved per browser like the skin itself. The skin is now also
- synced across open tabs, including one picked by hand. (#49)
-- Duplicate… in the sidebar's page menu copies a page right after it, as
- "
(copy)". For a page with subpages, tick "Include its N subpages"
- to copy them too. The copy gets its own drawings, images and files, so it
- keeps working if the original is archived, and links between the copied
- pages point at the copies. Up to 200 pages and 200 MB of files.
- `POST /api/docs/{id}/duplicate`. A server makes two copies at a time; when
- no slot frees up within five seconds, it answers `503 doc.duplicate_busy`
- with `Retry-After`. (#52)
-- Move up, Move down, Indent and Outdent in the sidebar's page menu, and
- Alt with the arrow keys on a page in the sidebar, move pages without
- dragging. (#52)
+- Skins can switch on their own (Settings → Appearance). Follow system uses
+ your light or dark skin with the operating system; Follow the sun uses the
+ light skin from sunrise to sunset, locating you by time zone or a place you
+ type in. Nothing leaves the browser, and the choice is saved per browser.
+ The skin now also syncs across open tabs. (#49)
+- Duplicate… in the sidebar's page menu copies a page, optionally with its
+ subpages, right after it as ` (copy)`. The copy gets its own
+ drawings, images and files, and links between copied pages point at the
+ copies. Up to 200 pages and 200 MB of files.
+ `POST /api/docs/{id}/duplicate`; a server makes two copies at a time and
+ answers `503 doc.duplicate_busy` with `Retry-After` when no slot frees up
+ within five seconds. (#52)
+- Move up, Move down, Indent and Outdent in the sidebar's page menu, and Alt
+ with the arrow keys on a sidebar page, move pages without dragging. (#52)
- Screen readers hear each sidebar page's level and number of subpages, how
to move it, and where a moved page ended up, instead of page ids. (#52)
### Changed
-- Creating a page with an `after_id`, or moving one with an `after_id` or
- `before_id`, that isn't a live sibling at the target level, or is the
- moved page itself, returns `422 doc.bad_anchor`. It used to put the page
- last. (#52)
+- The sidebar's keyboard drag (Space or Enter to pick up, arrows, Space or
+ Enter to drop) is gone. Use Alt with the arrow keys or the page menu. (#52)
+- An `after_id` or `before_id` on create or move that isn't a live sibling at
+ the target level, or is the moved page itself, returns `422 doc.bad_anchor`
+ instead of putting the page last. (#52)
- Restore returns `409 doc.sort_key_conflict` or `409 doc.restore_conflict`
- where it used to return a 500. Both can succeed on a retry. (#52)
-- Workspace import with a `?parent_id=` that is unknown or in another
- workspace returns `404 doc.not_found` (was `403 acl.editor_required`).
- (#52)
-- Create, move and create-from-template with a `parent_id` outside your
- workspace return `404 doc.not_found`, the same as for an id no page has.
- They used to accept it. (#52)
-- The sidebar's keyboard drag (Space or Enter on a page to pick it up, the
- arrow keys to move it, Space or Enter to drop it) is gone. Alt with the
- arrow keys, and Move up, Move down, Indent and Outdent in the page menu,
- move pages instead. (#52)
+ instead of a 500. Both can succeed on a retry. (#52)
+- A `parent_id` outside your workspace on create, move and
+ create-from-template returns `404 doc.not_found` (it used to be accepted),
+ and so does a workspace import `?parent_id=` that is unknown or in another
+ workspace (was `403 acl.editor_required`). (#52)
### Fixed
-- A line break (Shift+Enter) in a list item exported fine until the page
- went through a history restore, a template, or a Markdown or zip import,
- which stored it in a broken shape; a Markdown import stored a line break
- in an item of a tight list (one with no blank lines between its items)
- that way too. Markdown export then failed for the page, and with it
- search, share links, zip export, My Tasks, history preview and restore,
- and new pages from it as a template. An image or drawing in an item of a
- tight list was lost on the same paths. (#52)
-- Nested checklists, list items and quotes with several paragraphs, images,
- rules or lists, checkboxes in numbered lists, and empty list items lost
- their shape or content on the way through Markdown (export, history
- restore, templates and import). An HTML comment between two lines of a
- list merged them. (#52)
-- Markdown import dropped the line breaks of a code block in a list item or
- quote, dropped images and drawings in table cells, could move a cell's
- image into a later paragraph, and stored a lone `>` as a broken empty
- quote. (#52)
-- A page whose content was damaged by an earlier import, or holds content
- knot doesn't know, exports with that content as text instead of failing
- the whole page. (#52)
-- My Tasks listed other tasks than the checkboxes a toggle counts:
- checklists in table cells and checkboxes in numbered lists were left out,
- a bullet starting with `[ ]` was listed, and a nested checklist showed as
- one merged task. A toggle from My Tasks could check the wrong item. The
- listing now follows the page's checkboxes. (#52)
-- Creating the eighth page under the same parent failed with "Couldn't
- create document", and at the top level it silently gave two pages the
- same key, so their order could change. Adding pages after the same page
- failed the same way, a broken order key could crash the request, and
- every page added at the end made the keys one character longer. Pages
- two people create at the same level at the same moment no longer
- collide. (#52)
-- Workspace import skipped a page whose order key was taken and moved its
- subpages up. Imported pages now keep the export's order with fresh keys,
- so the same export can be imported twice. (#52)
-- Sidebar drag and drop nests, un-nests and reorders where the drop line
- shows: nesting worked only by luck of mouse speed, a page couldn't be
- moved out from under its parent when that parent was the last page, and
- rows slid around in an order that didn't match the screen. Dragging left
- or right picks the level. Releasing on the page you started from no
- longer reloads it, a drop outside the tree cancels, a drop that changes
- nothing sends nothing, and on touch screens a drag starts after a short
- press, so scrolling works. (#52)
+- Creating the eighth page under one parent failed with "Couldn't create
+ document", and at the top level silently gave two pages the same key, so
+ their order could change. Adding pages after the same page failed the same
+ way, a broken order key could crash the request, and every page added at
+ the end made the keys one character longer. Pages two people create at one
+ level at the same moment no longer collide. (#52)
+- Sidebar drag and drop lands where the drop line shows. Nesting no longer
+ depends on mouse speed, a page can leave its parent when that parent is the
+ last page, and dragging left or right picks the level. Releasing on the
+ starting page no longer reloads it, a drop outside the tree cancels, a drop
+ that changes nothing sends nothing, and on touch screens a drag starts
+ after a short press, so scrolling works. (#52)
+- Two pages moved at the same moment could end up inside each other's
+ subpages, in a loop, and moving or archiving one of them then hung a
+ database connection. Moves into a page now take turns, and moving or
+ archiving a page of such a loop finishes. (#52)
- Restoring a page whose top-level place was taken, or that was restored,
moved and archived again at the same time, failed with an internal error.
(#52)
-- Two pages moved at the same moment could end up nested inside each
- other's subpages, in a loop. Moving or archiving one of them then hung a
- database connection. Moves into a page now take turns, and moving or
- archiving a page of such a loop finishes. (#52)
-- On S3, a file the bucket refused to hand out (a 403 or a server error)
- was served as the bucket's error message instead of failing. (#52)
+- Workspace import skipped a page whose order key was taken and moved its
+ subpages up. Imports now keep the export's order with fresh keys, so the
+ same export can be imported twice. (#52)
+- A line break (Shift+Enter) in a list item was stored in a broken shape by
+ history restore, templates, and Markdown or zip import (Markdown import
+ also did it in tight lists). Markdown export then failed for the page, and
+ with it search, share links, zip export, My Tasks, history preview and
+ restore, and using the page as a template. Images and drawings in items of
+ a tight list were lost on the same paths. (#52)
+- Markdown export, history restore, templates and import keep nested
+ checklists; list items and quotes with several paragraphs, images, rules or
+ lists; checkboxes in numbered lists; and empty list items. An HTML comment
+ between two lines of a list no longer merges them. (#52)
+- Markdown import keeps the line breaks of a code block in a list item or
+ quote, keeps images and drawings in table cells where they are, and no
+ longer stores a lone `>` as a broken empty quote. (#52)
+- A page with content damaged by an earlier import, or content knot doesn't
+ know, exports that content as text instead of failing the whole page. (#52)
+- My Tasks lists exactly the page's checkboxes. Checklists in table cells and
+ checkboxes in numbered lists were missing, a bullet starting with `[ ]` was
+ listed, a nested checklist showed as one task, and a toggle from My Tasks
+ could check the wrong item. (#52)
+- On S3, a file the bucket refused to hand out (a 403 or a server error) was
+ served as the bucket's error message instead of failing. (#52)
Thanks to @szEvEz, whose #42 found the list export, page order and sidebar
-drag and drop bugs above, asked for Duplicate, and proposed the first
-fixes.
+drag and drop bugs above, asked for Duplicate, and proposed the first fixes.
### Security
-- A crafted workspace-import zip with pages that are each other's parent, or
- a very deep chain of parents, crashed the whole server for everyone. Any
- editor could upload one. The bug predates this release. (#52)
-- A page nested a few thousand levels deep, made with a crafted Markdown
- upload or editor update, crashed the whole server for everyone when knot
- exported it. Any editor could make one. The bug predates this release.
- Markdown import and export now follow at most 128 levels of quotes, list
- items and table cells, and turn anything deeper into one paragraph of its
- text, so no text is lost: Markdown and zip import, history restore and
- templates store it that way, and a page already stored deeper exports its
- deep part that way. An editor update that nests a page past 512 nested
- types is refused, and its connection closes with code 4422. The editor
- then stops reconnecting and shows "Changes refused" until the page is
- reloaded, instead of sending the refused update again. Two cases
- remain known limitations, as before this release (see #53): a single
- crafted editor update whose own apply nests and deletes deep enough, and
- deleting the deep part of a page already stored deeper, can still crash
- the server. (#52)
-- `pulldown-cmark` 0.13.5 fixes RUSTSEC-2026-0414: with wiki links enabled,
- a crafted Markdown input could make it produce exponentially many events.
- knot never enables wiki links, so it was not affected. (#52)
+- Any editor could crash the whole server for everyone with a
+ workspace-import zip whose pages are each other's parent, or that has a
+ very deep chain of parents. The bug predates this release. (#52)
+- Any editor could crash the whole server for everyone by exporting a page
+ nested a few thousand levels deep, made with a crafted Markdown upload or
+ editor update. The bug predates this release. Markdown import and export
+ now follow at most 128 levels of quotes, list items and table cells and turn
+ anything deeper into one paragraph of its text, so no text is lost. An
+ editor update that nests a page past 512 levels is refused with close code
+ 4422; the editor then shows "Changes refused" and stops reconnecting until
+ the page is reloaded. Two cases remain known limitations (#53): a single
+ crafted update whose own apply nests and deletes deep enough, and deleting
+ the deep part of a page already stored deeper. (#52)
- Create, move, create-from-template and workspace import accepted a parent
page from another workspace and put the page under it. They now return
`404 doc.not_found`. The bug predates this release. (#52)
-- `ctutils` 0.4.5 fixes RUSTSEC-2026-0385: its constant-time helpers could
- compile to branches on secret values. It reaches knot only through the
- Postgres client's password authentication. knot's own session and CSRF
- signing do not use it. (#45)
-- `tokio` 1.53.3 fixes RUSTSEC-2026-0386: `AsyncFd`'s safe constructors could
- register a file descriptor knot does not own, leading to a use-after-free
- when the descriptor number was reused. knot does not call `AsyncFd` itself;
- it reaches knot only through its dependencies. (#47)
-- RUSTSEC-2026-0387 (`crypto-bigint` 0.5.5, timing side channels) is
- acknowledged in `deny.toml`. knot was not affected: the crate is used only
- to verify OIDC ID-token signatures, which involves public values only, and
- the fix exists only in a major version that `openidconnect` does not yet
- support. (#47)
-- `dompurify` 3.4.17 fixes six advisories in its `SAFE_FOR_TEMPLATES` and
- `IN_PLACE` modes, its `application/xhtml+xml` parser mode and node-removing
- hooks. knot was not affected: neither knot's sanitiser nor mermaid uses
- those settings, and mermaid's hooks remove no nodes. (#55)
-- `source-map-js` 1.2.2 and `postcss-selector-parser` 6.1.5 fix CPU and
- event-loop denial of service from crafted source maps and CSS selectors.
- Both run only when the web app is built, on knot's own sources. (#55)
-- Development tooling only, none of it shipped: `vite` 7.3.7, `vitest`
- 4.1.12, `undici` 7.30.1 (through `jsdom`), `shell-quote` 1.12.0 (through
- `concurrently` 10.0.6), `brace-expansion` 1.1.21 and 5.0.12, and `zod`
- 4.6.7 (through `eslint-plugin-react-hooks`). (#55)
-- `katex` 0.16.47, which mermaid draws math labels with, keeps
- GHSA-238p-pmpm-9mq7 (low): it matters only if something else on the page
- already allows prototype pollution. The fix needs mermaid 12, which changes
- the layout and look of every existing diagram, so it waits for that
- upgrade. `web/SECURITY-NOTES.md` records it. (#55)
+- Dependency fixes:
+ - `tokio` 1.53.3: RUSTSEC-2026-0386, a use-after-free through `AsyncFd`'s
+ safe constructors. knot reaches it only through its dependencies. (#47)
+ - `ctutils` 0.4.5: RUSTSEC-2026-0385, constant-time helpers that could
+ compile to branches on secrets. Used only by the Postgres client's
+ password authentication, not by knot's session or CSRF signing. (#45)
+ - `pulldown-cmark` 0.13.5: RUSTSEC-2026-0414. Not affected: knot never
+ enables wiki links. (#52)
+ - `dompurify` 3.4.17: six advisories in modes and hooks that neither knot's
+ sanitiser nor mermaid uses. Not affected. (#55)
+ - `source-map-js` 1.2.2 and `postcss-selector-parser` 6.1.5: denial of
+ service from crafted inputs. Both run only when the web app is built, on
+ knot's own sources. (#55)
+ - Development tooling only, not shipped: `vite` 7.3.7, `vitest` 4.1.12,
+ `undici` 7.30.1, `shell-quote` 1.12.0, `brace-expansion` 1.1.21 and
+ 5.0.12, `zod` 4.6.7. (#55)
+- Acknowledged, not fixed:
+ - RUSTSEC-2026-0387 (`crypto-bigint` 0.5.5, timing side channels), in
+ `deny.toml`. Not affected: it only verifies OIDC ID-token signatures,
+ which involve public values only. The fix needs a major version
+ `openidconnect` doesn't support yet. (#47)
+ - GHSA-238p-pmpm-9mq7 (`katex` 0.16.47 through mermaid, low), in
+ `web/SECURITY-NOTES.md`. It matters only if something else on the page
+ already allows prototype pollution. The fix needs mermaid 12, which
+ changes the look of every existing diagram. (#55)
## [0.8.1] - 2026-10-21