docs/known-limitations.md
Rama main · réplica de lectura del repositorio original.
# Known limitations
Per-person rate figures in this document for API and MCP reads and writes, note content reads and writes, searches, uploads and copies, AI actions, bulk downloads and bulk write-backs are the defaults; a site admin can lower each of them in Site admin → Site limits (see [Site limits](./self-hosting.md#site-limits)). The other rate limits are fixed.
- **A flood of distinct accounts or addresses can push an active login lockout out of memory.** The failed-login throttle keeps at most 10,000 records per track (account and IP), evicting whichever record's last failure is oldest — so an attacker who can produce more recent failures across that many distinct keys, faster than the locked-out record's backoff window (at most 15 minutes), can drop someone else's lockout. Doing so costs thousands of requests from many distinct source addresses, since the attacker's own address gets locked out after 5 failures and every attempt still pays for a password hash. The bound itself is not optional: without it the tables grow for the life of the process.
- **A media URL that fails the safety check shows up as a broken image, with no explanation.** Image, video, audio and file blocks only render URLs that resolve to `http(s)` — anything else (a `javascript:` or `data:` URL written straight into the collaborative document, bypassing the embed field) is replaced with an inert placeholder at render time, so it looks exactly like a broken link. The check runs in each reader's browser: the offending string stays in the document and is still delivered to everyone, there is no UI that shows or clears it, and the only fix is to delete the block.
- **The "opens in a new tab" icon on a link can wrap onto a line of its own.** A link to a web address is followed by a small icon, and a line is allowed to break between the link's text and that icon — so the text can end one line with the icon alone at the start of the next. This is appearance only: the link itself, where it goes and what it is labelled, is unaffected (#153).
- **A few unusual ways of writing a web address get no "opens in a new tab" icon.** The icon is chosen by how the link's address is written: it appears when the address starts with `http:` or `https:` in any letter case (`HTTPS://example.com`), or with `//` (an address with the scheme left out, such as `//example.com`) or its backslash spellings (`\\example.com`, `/\example.com`, `\/example.com`). Some spellings the browser still reads as a web address are not recognized — for example an address that starts with a space or another invisible control character (` https://example.com`), or one with a tab or line break anywhere in it (the browser drops those characters, so `/`, a tab, then `/example.com` is read as `//example.com`). Such a link still opens the site in a new tab when clicked, but shows no icon. A link that has become a link to a note on this site opens in the same tab (see the entries on links to notes below); any other link to a path on this site — for example a public page (`/p/…`), a note address with `?present`, or a link that stayed an ordinary link — still opens in a new tab with no icon. The gap only ever leaves an icon out: it is never shown on a link that does not open a page in a tab, such as `mailto:` and `tel:` (#156).
- **Rolling back to a pre-0.2 version after starting 0.2 makes stored AI provider keys unreadable.** On first start, 0.2 rewrites each stored AI provider key into a format that binds it to its provider; older versions do not understand that format and treat those keys as corrupt. The providers show as degraded and their keys have to be entered again. Nothing else in the database is affected.
- **A tab that cannot reach the collaboration server keeps retrying in the background; edits made while offline are still lost if you reload before it recovers.** When the collab token cannot be fetched — or when the server rejects the handshake for a reason that is not an access decision, such as a token that failed to verify or an unexpected server-side error — the tab restarts the whole handshake every 5, 15, then 60 seconds (jittered) for as long as it stays open, which recovers on its own once the underlying problem is fixed. The connection badge escalates to a warning after a few seconds ([issue #48](https://github.com/sw-willie-wu/knotebook/issues/48)): if the note had already synced once, it reads "Editing offline — changes not synced yet" and the editor stays writable, and those edits merge back **when the connection recovers**; if it never synced (the note failed to load at all), the editor is read-only and the badge reads "Not loaded — offline", so you cannot type into a blank document at all; a read-only viewer sees a plain "Offline" rather than an editing message. The remaining data-loss window is the first case: there is no `y-indexeddb`, so anything typed while "Editing offline" lives only in that browser tab and is lost if you reload or close it before the connection comes back. Those retries count against the per-user collab-token rate limit, which is shared across every open tab, so a user with many stuck tabs can exhaust their own quota. This is also what a server-side error during the handshake looks like from the browser, which is why it is logged on the server — see [Troubleshooting](./self-hosting.md#troubleshooting).
- **A note move to the trash that fails partway still ejects everyone who had it open.** Deleting a note — which moves it to the trash — closes every live connection to it *before* the transaction that moves it runs, and each of those tabs converges on "this note was deleted" and returns to the note list. If the transaction then fails and rolls back, the note is still where it was, not in the trash — but nothing calls those people back, and they were told something untrue. Reopening the note works (the server releases its delete gate as soon as it sees the transaction failed), so the cost is the wrong message and the interruption, not lost work.
- **An administrator can still obtain AI provider API keys, just not silently.** A provider's key is sent, in plaintext, to whatever host its Base URL names — that is what configuring a provider means. Changing the Base URL therefore clears the stored key, so a key entered for one host cannot be redirected to another. What remains is the slower path: an admin points a provider at a host they control, and waits for someone to enter a key again. That leaves traces — the provider is marked as having no key in Settings, and the server logs `AI provider 的 base URL 被寫入` (on every edit that includes a Base URL, whether or not it changed; creating a provider does not log it) with the account that did it and the old and new target — but nothing prevents it. Do not rely on noticing the URL itself: it is shown truncated, and `https://api.openai.com@evil.com/v1` reads as the real thing at a glance. **The log line is the trustworthy trace** — specifically its `to` and `hasKeyAfter` fields, which come from the write itself; `from` is read separately and can lag under concurrent edits. Separately, because Base URL is deliberately unrestricted (so that you can point a provider at a host on your own network), an admin can also make the server issue requests to arbitrary addresses, including ones only it can reach; the response body is never returned to them, but whether the request succeeded is observable. **Administrator is the highest privilege level in Knotebook; treat it as equivalent to holding every configured provider key.**
- **An error response from the AI provider is written to the server log, and a gateway that echoes credentials in it puts them there.** When a provider answers with a non-2xx status — during an AI action, or when you test a provider's connection in Site admin — the server logs the status and the first 2,048 characters of the response body at `warn` level (the body is never sent to the browser). Most providers' error bodies hold only a message, but a few gateways and proxies repeat the `Authorization` header or part of the key in them, and then that text lands in the log. Only someone who can read the server's logs sees it; the Knotebook log line does not carry the key itself. If you ship logs to another system, treat them as sensitive, or filter the provider-error lines (`upstreamBody` on AI actions, `body` on the connection test) before they leave the host. Tracked in [issue #211](https://github.com/sw-willie-wu/knotebook/issues/211).
- **In-memory rate limiting and login lockout reset on restart.** Failed-login backoff counters, the password-hashing concurrency semaphore, the password reset link and invitation limiters (invalid links per IP, attempts per link), the collab-token/slug-change rate limiters, and the short-lived gate that lets a just-deleted note report itself as deleted rather than inaccessible all live in process memory; restarting the `app` container clears them. This is also why they cannot be shared across instances — see the [single-instance warning](./self-hosting.md#single-instance-warning).
- **Email suggestions show the emails of everyone you share a group with.** The add-member and share forms suggest people as you type (see [Sharing](./sharing.md#the-three-access-levels)), drawn from your groups' members and the people you've shared notes with. Those emails were already visible to you — any member can open a group's member list — so the suggestions don't reveal anyone new, but they make it quicker to look through: in a large group, every member's email is a few keystrokes away. Disabled accounts aren't suggested, but can still be added by typing their full email. The lookup is limited to 60 per minute per user — typing many addresses into a group's **Members** box can reach it, and suggestions then stop until the minute is up, without a message — and like the other in-memory limits, that resets on restart.
- **Email matching is case-insensitive; only new writes are stored lowercased.** Every account-writing path (self-registration, accepting an invitation, environment-variable admin bootstrap, OIDC auto-provisioning/linking) normalizes the address to lowercase before writing it, and every lookup (login, sharing, OIDC account linking) compares via `lower(email)` — so `alice@example.com` and `Alice@example.com` now resolve to the same account. The one caveat: this doesn't retroactively merge any pre-v0.1 rows that already exist as separate accounts differing only by case (from before this normalization was in place). Those remain two distinct users; `lower()` matches both at once, and login/sharing deterministically fall back to the older row (see the duplicate-row runbook below), while OIDC linking refuses to guess and rejects with `oidc_conflict` instead. If you're setting up a fresh instance you'll never hit this — it only matters when carrying over data from a database that predates case-insensitive matching.
- **Resolving pre-v0.1 duplicate email rows (e.g. `alice@example.com` vs. `Alice@example.com`).** If two `users` rows exist whose emails differ only by case, `lower(email)` lookups hit both: `POST /api/auth/login` and note sharing pick the older row deterministically (`ORDER BY created_at, id LIMIT 1`) and silently ignore the newer one, while OIDC login/linking rejects with `oidc_conflict` (`multi_email_match`) rather than guessing which one you meant. Detect duplicates with:
```sql
SELECT lower(email) AS normalized, array_agg(id) AS user_ids, array_agg(email) AS raw_emails
FROM users GROUP BY lower(email) HAVING count(*) > 1;
```
Fix by picking one canonical row per group — the older one, to match the login/sharing behavior above — migrating anything owned by the other row(s) (owned notes' `owner_id`, `note_shares` rows, etc.) onto it, then either deleting the now-empty duplicate row(s) or renaming their `email` to something outside the group so it stops colliding. Finally normalize the canonical row's own `email` to lowercase (`UPDATE users SET email = lower(email) WHERE id = '<canonical-id>';`) so it matches what every write path stores today. There's no automated migration for this in v0.1 — it's a manual, one-time cleanup for instances carrying data forward from before case-insensitive matching existed.
- **Redoing across an "undone to empty" boundary while a collaborator is typing can leave a stray empty block.** Redo after undoing a note back to empty works reliably when you're editing alone (issue #100). In the narrow window where a collaborator writes into the note between your undo-to-empty and your redo, the cleanup step deliberately stands down (removing anything a collaborator may now depend on risks losing their work), so the redo can restore your content alongside the placeholder paragraph as a separate empty block. In the paths we have been able to construct, nothing is lost — your restored text and the collaborator's edits are both kept — and deleting the stray empty block by hand resolves it.
- **A collaborator's edits to the blocks you are dragging can be lost.** A block dragged by its ⋮⋮ and dropped above or below another block lands with the content it had when you started dragging, so what a collaborator types into it while you hold it disappears. Dropping it beside another block or into an empty column keeps what they typed. When a collaborator deletes one of the dragged blocks before you let go, the drop brings the deleted block back and an empty paragraph can remain where the blocks were.
- **The editor's collaboration library, y-prosemirror, is patched.** `patches/y-prosemirror@1.3.7.patch`, applied by pnpm at install, puts the cursor at the nearest text when a block selected as a whole is gone by the time y-prosemirror restores the selection, where the unpatched library raises an error and leaves the note out of step with your collaborators' (#428); upgrading y-prosemirror means carrying the patch over or dropping it once the library handles this itself, and `apps/web/src/collab/node-selection-restore.test.ts` fails when it no longer applies.
- **A note's title is not part of the real-time collaborative document.** The block editor content syncs live over Yjs/Hocuspocus, but the title is a separate field updated via `PATCH /api/notes/:id` with last-write-wins semantics — two people renaming a note at the same moment can silently overwrite one another, unlike the CRDT-merged body. A rename made over MCP is the exception on its own side: it carries the fingerprint of the title it replaces and is refused if the title is no longer the text it had when that fingerprint was taken — a title changed and then changed back to the same text counts as unchanged (#381) — but a rename in the web app still overwrites whatever title is there, including one an MCP client just set.
- **Resetting someone else's password takes an admin and a link they send themselves, and it doesn't sign out the person's API tokens.** There is no email and no self-service "forgot password": an admin creates a one-time link in **Site admin → Users** (valid 1 to 24 hours) and passes it on. Completing a reset signs out the person's other browser sessions but doesn't revoke their API tokens or connected apps — revoke those separately in Settings → Authorization if the account may be compromised. An admin can open the link they made, in a browser where they are signed out, and set the password themselves; nothing prevents that, but it isn't silent: the person's other browsers are signed out, their old password stops working, and the server logs both creating and using the link. Self-service password change has a UI now (**Settings → Sign-in**, reachable once signed in via the user menu; any account still flagged to change its password — such as an account an admin created with a temporary password in **Site admin → Users** before invitations replaced that, or, on instances set up before this change, an env-bootstrapped admin that hasn't changed its password yet — is instead routed to the standalone `/change-password` page automatically when they sign in, with a password or through SSO); changing it yourself needs your current password.
- **A password reset link or an invitation link can end up in browser history or in a chat.** The link keeps its token after a `#`, which the browser doesn't send to the server, and the reset page and the invitation page (`/register` with a link) remove it from the address bar as soon as they load. But before that, the address may already have entered the browser's global history, and it stays in whatever chat tool or email carried it. It works once and for at most 24 hours (an invitation, at most 7 days); cancel an unused reset link, or revoke an unused invitation, in **Site admin → Users** if it went to the wrong place.
- **Password reset links and invitations share two in-memory limits, and neither one is what stops guessing.** Invalid links of either kind are limited to 30 per minute per IP together (`ONE_TIME_LINK_MISS_LIMIT`; then `429 too_many_requests`), and completing a reset or accepting an invitation is limited to 10 attempts per hour per link (`ONE_TIME_LINK_ATTEMPT_LIMIT`). Both reset on restart. The first doesn't reduce database work and doesn't hide whether a guess was right — a valid link is never blocked by it. What protects a link is that its token is random, 256 bits long, and can't be guessed; the first only changes the answer (`429` instead of `410`) for a client that keeps sending invalid links. The second only matters to someone who already holds a valid link and keeps sending requests that fail.
- **The old `/settings/users`, `/settings/ai` and `/settings/groups/<id>` paths redirect.** `/settings/users` and `/settings/ai` go to `/admin/users` and `/admin/ai` — site administration is its own panel, opened from **Site admin** in the user menu (admins only); `/settings/groups/<id>` goes to the group's panel at `/groups/<id>`, and `/settings/groups/<id>/roles` to its **Roles** page. `/admin/auth` and `/admin/versions` go to `/admin/login` and `/admin/storage`. Bookmarks to the old paths still work; a non-admin following an `/admin/*` link, or one of the old `/settings/users` and `/settings/ai` paths, ends up on the home page.
- **Closing a panel navigates forward, not back.** Opening Settings, a group's panel or Site admin pushes a new browser history entry rather than replacing the current one; closing it (Esc or the ✕; clicking outside doesn't close it) navigates back to wherever you were. If you then press your browser's own Back button, it reopens the panel instead of leaving the page you were on before. Switching to another page inside the panel (from its list of pages) also adds a history entry, so it takes one extra Back press for the panel plus one for each switch you made, including going back to a page you had already visited.
- **On a narrow screen, the pinned name column can cover part of a row's first button.** A table too wide for the panel scrolls sideways inside its own frame while its name column stays pinned on the left. In the tables with four buttons a row — **Actions** in **Site admin → AI agents** and **Sign-in services** in **Site admin → Sign-in** — the name column can still cover part of the leftmost button once the table is scrolled all the way to the right, and a click on the covered part lands on the name instead. Scroll back a little, or move to the button with Tab: when a button or switch that the name column partly covers gets keyboard focus, the table scrolls so it is no longer covered. Only the top-level name column is pinned: the models listed under each AI provider scroll with the table, names included, and so does what is shown under a row — such as a sign-in service's callback URL, a warning, or **Add model** at the top of a provider's models — so on a narrow screen you may need to scroll right to see all of it.
- **A deleted built-in AI action reappears on the next server restart.** The five built-in AI actions (rewrite/translate/summarize/continue/instruct) are re-created idempotently on every startup if missing. Renaming or disabling a built-in action persists normally — only deletion is undone. If you don't want a built-in action available, disable it (or rename it to repurpose it) rather than deleting it.
- **The Instruct action's guard is a prompt, not a filter.** The instruction, the highlighted characters and the selected text are put inside fixed tags without any escaping, so text that itself contains something like `</text>` can blur where one part ends, and whether an instruction is off-topic is left to the model. The guard tells the model to return the text unchanged for an off-topic instruction, but that isn't guaranteed; when it does, the AI box says there are no changes, which is not an error.
- **The AI's instruction to keep columns and sizes is a prompt, not a filter.** Every AI action asks the model to keep the [side-by-side column](./columns.md) tags and the `width` and `colwidth` values in the text it is given, and a result that writes them in another form is refused. A result that leaves that markup out altogether passes the checks, though: applying it replaces the blocks it was made from like any result, so a row it was given turns into ordinary blocks and the image widths and table column widths it left out are gone. **Revert** (after a "direct" action) or Ctrl+Z undoes it. See [AI](./ai.md).
- **A selection that includes or is inside a code block shows no formatting toolbar, so the AI box can't be opened on it.** This comes from the editor's toolbar rule, which hides the toolbar whenever a selection includes or is inside a code block — including Ctrl+A on a note that contains one. To run AI on such a note, select the parts around the code block one at a time.
- **If the code for the AI box's preview fails to download, a result you haven't applied yet is lost.** The preview is loaded on demand together with version history; when that download fails the page reloads to fetch it (automatically at most once after each successful load; after that, its **Try again** button reloads the page), and the reload discards the AI result that was waiting in the box.
- **On touch screens, custom AI actions in the AI box have no visible name.** The built-in actions have their own icons; custom actions share one generic icon whose name shows only as a mouse tooltip or to screen readers.
- **The AI box's result covers the text below the selected blocks until you close it.** When a response finishes, the box moves on top of the selected blocks and extends downward over the paragraphs that follow; to read them, apply, discard or close the result first.
- **The AI box's result stays at the top of the note's scrolling area once the selected blocks scroll up out of view.** It keeps covering whatever note text is there, even after the selected blocks have scrolled away entirely, until you apply, discard or close it.
- **The AI box takes the width of the first block it works on.** That is the first selected block — or, when a selection reaches across [side-by-side columns](./columns.md) or into or out of a row and the action works on whole top-level blocks, the first of those, so when such a selection starts inside a row, the box is as wide as the whole row. Its preview wraps lines like the note does for that block, but when a selection spans blocks at different indent levels, the other blocks may wrap differently in the preview than in the note.
- **When the AI box's preview is taller than the box, a scrollbar can make its lines wrap differently than in the note.** The box is at most 40% of the window's height and scrolls beyond that; on systems whose scrollbars take up space instead of overlaying the content, the scrollbar narrows the preview, so a long result may wrap at different points than the same text in the note.
- **In the AI box's preview, code blocks show no coloured bar for added, removed or changed blocks.** The bar is drawn as an inset shadow on the block, and the code block's full-width background paints over it; the strike-through and fading of removed blocks still show. The version history panel marks blocks with a left border instead and is not affected.
- **The AI box's instruction field grows with its text, up to four lines, in browsers that support CSS `field-sizing: content`.** Firefox doesn't as of October 2026, so there the field stays one line tall and scrolls inside itself.
- **A note's automatic URL changes the moment its title does — with no grace period.** Note URLs are `/n/<username>/<slug>` for personal notes and `/g/<group id>/<slug>` for group notes (#122, #175). Unless you set a custom slug, the slug follows the title: renaming the note usually retires the old automatic URL immediately (no redirect). Custom slugs get exactly **one** level of grace: changing a custom slug leaves a single redirect from the immediately-previous custom name; changing it again drops that. In-app **link data** is keyed on the note's id — a link to a note inside a note (made with `[[`, or by pasting, dragging in, typing or writing that note's address, including over MCP) or a sidebar entry never points at the wrong note, and live collaboration is unaffected — but the URL such a link navigates to is computed from your cached copy of the note, which can be momentarily stale (see the stale-sidebar entry below); only shared URL *strings* break permanently.
- **A released slug can be re-used by the same owner's other notes (or the same group's) — an old link can silently start showing a different note.** Slugs are unique per user for personal notes and per group for group notes, not globally (`409 slug_taken` now only means *another of your own notes* (or, on a group note, another note in the same group) has that name). If you free a slug (by renaming a custom one, changing a title, or permanently deleting the note — moving it to the trash doesn't free it) and later another of your notes (or, in a group, another note of that group) takes the same name, old copies of that URL resolve to the new note with no warning.
- **Editors can change a note's automatic URL out from under the note's owner (or, on a group note, the rest of the group), silently and without limit.** Anyone with edit access can rename the note; if its slug is automatic, that rename recomputes the URL — with no rate limit on title changes and no redirect left behind. This is deliberate: an editor is a trusted collaborator who can already rewrite the note's entire content, and the abuse surface is the same as "an editor renaming your note maliciously". Custom slugs are owner-only (on a group note: a role that can manage public links). Over MCP, `edit_note`'s `rename` does the same; with a token it counts against the 60-per-10-minutes token write budget, and over a browser session there is no limit.
- **Old-form `/notes/…` links keep working forever, and are actually more durable than the new form.** Links that existed before the URL change (the frozen pre-#122 slug, and any `…-<uuid>` form) resolve permanently, because they don't depend on the owner's username. The new `/n/<username>/…` form breaks if the owner renames their username — that fragility is the inherent cost of removing the uuid from URLs.
- **Clicking a note in your own sidebar can hit a dead link for a moment after a collaborator renames it.** In-app links are built from your cached copy of the note list; if a collaborator just changed the title (and with it the automatic slug), a click in the stale window lands on "This link is invalid" and returns you home. Refreshing the sidebar (it refetches on window focus) resolves it. This was accepted over an id-fallback carried in navigation state, for two reasons: such state only survives in-app clicks (a refresh, a new tab, or a pasted link never has it, so it's half a fix at the cost of a third resolution path), and silently rerouting a failed URL to an id would hide the "this link is dead" signal from the person who most needs to re-share it.
- **The URLs assigned during the URL upgrade migration (#122) can shift once, silently, on the first title save.** The migration derives a slug from the title in SQL for each note **without a custom slug** (custom slugs carry over untouched and never recompute), which handles ASCII only — a note with a non-ASCII title (Chinese, accents, …) comes out of the migration as `untitled(-n)`, and the first time anyone saves its title afterwards, the in-app derivation (which keeps CJK and accented characters) recomputes it, changing the URL once with no redirect.
- **The sidebar's width doesn't adapt to the window.** The width you drag it to (200–480 pixels) is remembered per browser and kept as is when the window gets smaller, so a sidebar widened on a large screen can leave the note too narrow on a laptop. Double-click the gap between the sidebar and the note to go back to the default width.
- **The Recent tab doesn't reorder for edits made elsewhere until the note list refreshes.** It follows the note you have open as soon as its header shows the new edit time, but an edit someone else (or an AI client) makes to another note moves that note up only after the list is fetched again — when the window regains focus, the page reloads, or you create, move or delete a note.
- **The result count may not be announced by a screen reader when you type the first character of a search.** The sidebar's search results and their count appear together, and some screen readers skip a live region that is inserted with its text already in place; the count is announced when it changes on later keystrokes. The count now covers title and body-text matches together and changes once the body-text search has finished (while a newer search is still running it shows only the title matches); it is the one live count line. The line saying the body-text search is running or has failed is also a live region, with the same gap when it appears with its text already in place. The "load more" error line and the "no notes match" line are not live regions, and neither is the "Searching note text…" line shown while the search code is still downloading.
- **The version history panel's width doesn't adapt to the window either.** On a wide screen the panel keeps the width you drag it to (240–560 pixels, remembered per browser), so with both the sidebar and the panel widened, a laptop-sized window leaves the note only a narrow strip. Double-click the panel's left edge to go back to 320 pixels (#274).
- **Notes with the same title have sidebar `⋮` buttons that sound the same to a screen reader.** Each is named after its note ("Note actions for *title*"), so several notes called, say, "Untitled" give several identical "Note actions for Untitled" buttons; tell them apart by the row they sit in.
- **Moving a note to the trash from the sidebar while that same note is still opening doesn't always end the same way.** If you click a note and use its sidebar `⋮` to move it to the trash before the page has found the note, the page treats it like moving a different note and doesn't leave on its own. What follows depends on timing: if the page finds the note already in the trash, it shows "This note is in the trash" with **Restore** — or, when your role in the group can delete notes but not edit them, says "This link is invalid or the note doesn't exist." and takes you to the home page; if the page found the note just before the move, it says "This note has been deleted." and takes you to the home page. Either way the note is in the trash.
- **Admins cannot delete user accounts, only disable them.** Account deletion (including note ownership transfer) is not implemented yet; tracked in [issue #73](https://github.com/sw-willie-wu/knotebook/issues/73).
- **"Online" in Site admin → Users means recently active, not connected right now.** The **Last active** column holds the last time someone used the site while signed in — a browser request to an endpoint that requires being signed in, opening a note for live editing (as a read-only viewer too), a change they make to a note over that live connection, or signing in — and shows **Online** while that time is less than 5 minutes old by the clock of the browser showing the list. It is worked out when the list is drawn — nothing re-checks it on a timer, so a list left open can keep showing **Online** after the 5 minutes are up. Requests made with an API token or a connected app (MCP) don't count, nor does coming back from linking a sign-in service in Settings, nor just having a page open: someone who leaves a note open without typing or otherwise using the page drops out of **Online** 5 minutes after their last activity. To keep writes down, each server process records an account at most once a minute, and the database skips the write while the stored time is less than a minute old, so with one server process the time shown usually trails someone's latest activity by less than a minute and someone who keeps working stays **Online**; with several processes it can trail by more, because each process counts its minute on its own (the database check still allows only one write per account a minute, and each process tries at most once in that time). Activity while another change to the same account is in progress, or while the database can't be reached, can go unrecorded, and that process doesn't try again for that account for a minute.
- **Notes in the trash are deleted for good after the retention period, usually within an hour of it.** Deleting a note (`DELETE /api/notes/:id`) moves it to the trash; it stays there for the site's retention period (7 days by default — see [Trash](./self-hosting.md#trash)), counted from the moment it was moved. The server clears out expired notes in a background pass once an hour (the first one 5 minutes after it starts), at most 2,000 notes per pass, oldest first. So a note usually outlasts its expiry time by up to an hour; when more than 2,000 are due at once — after the retention period is shortened, for example — the rest wait for later passes and it takes several hours to clear them all, and nothing is cleared while the server is down. Clearing out deletes the note with its shares, versions, history and attachments; files are removed from disk best-effort after the transaction commits, and one that can't be removed is orphaned — see "Uploaded file cleanup is not automatic" below. With the retention period set to keep notes until someone deletes them, notes stay in the trash indefinitely, and their attachments keep counting toward storage.
- **A note in the trash still holds its URL and its custom public URL.** While it's in the trash, another note in the same space (yours, or the same group's) given the same title gets a numbered URL (such as `…-2`), setting another note's custom URL to it answers `409 slug_taken`, and its custom public URL name can't be given to another note (`409 public_slug_taken`) until the note is permanently deleted. Restoring it brings both back unchanged. This also means a group member who can't see the group's trash (their role can edit but not delete, for example) and changes a note's title or URL to one a note in the trash holds learns that a note they can't see has that URL — only that the URL is taken, never that note's title or content. This is accepted: freeing the URL instead would make restoring a note fail whenever its URL had been reused.
- **Deleting a group with Delete everything also permanently deletes the notes in its trash.** They don't go to anyone's trash first; nothing in the group can be restored afterwards.
- **Someone whose role can delete notes but not edit them can't get back a note they deleted.** A group's trash is visible only to members whose role can both edit and delete notes, so a note such a person deletes disappears for them; a member whose role has both, such as an Admin, can restore it. Right after the deletion, the web app's message says who can restore it instead of offering **Undo**. Over MCP it is the same: after such a person's `delete_note`, `list_trash` doesn't list the note and `restore_note` answers `not_found`.
- **An image copied into another note breaks while the note it was uploaded to is in the trash.** Access to an image is decided by the note it was uploaded to (see "An image block copied and pasted into another note…" below), so where it was copied into another note it fails to load (`403`) until that note is restored, and for good once that note is permanently deleted.
- **A direct write can land in a note that is being moved to the trash.** Moving a note to the trash waits up to 5 seconds for the server to close the note's live document. If that doesn't happen in time while a write that doesn't go through the editor is in progress — `POST /api/notes/:id/edits`, a revert, applying a version, or MCP `edit_note` — the note is moved anyway and that write can end up in the note in the trash. Nothing is lost: it shows once the note is restored.
- **Markdown that takes the server too long or too much memory to read is refused.** The server reads written Markdown into note content in parse workers — threads of their own with their own memory limit — so other people's requests and live editing keep running meanwhile (#398). A write whose Markdown takes longer than `PARSE_TIMEOUT_MS` (default 10 seconds) or more memory than `PARSE_WORKER_MEMORY_MB` (default 1024 MiB) to read is refused with `unsupported_block`, its message saying the content is too complex, and nothing is written. How long and how much depends on what the Markdown holds more than on its size. Measured on a machine of 2 vCPUs and 4 GiB: about 256 KB (the largest write accepted) of ordinary formatted Markdown takes about 1.4 seconds and fits in 256 MiB; the slowest content measured that still fits the defaults is 256 KB of words separated by `<br>` in one paragraph, about 7 seconds and about 640 MiB. Dense formatting inside quotes or `<details>` needs more memory the deeper it sits — 256 KB of bold words inside quotes nested 5 levels needed about 1.5 GiB — and is refused with the defaults; Markdown nested several thousand levels deep runs into the time limit. (Quotes nested 128 levels deep and lists nested 64 levels deep are refused for their depth.) A site that needs such content can raise the memory limit (Site admin → Site limits, up to 3072 MiB, or `PARSE_WORKER_MEMORY_MB`) or, with `PARSE_TIMEOUT_MS`, the time limit; see [Self-hosting](./self-hosting.md#parse-workers). This applies to `edit_note`, `create_note`, `POST /api/notes` with `content`, `POST /api/notes/:id/edits` and files sent back or created over MCP bulk transfer alike.
- **One person can keep parse workers busy, and when every worker is busy, others wait.** Reading the Markdown of a write, putting a write into a note, and turning a note of 16 KiB or more back into Markdown (reading it over MCP or the API, downloading it) all queue for the same parse workers. One person uses at most two workers at a time, and a second only while nobody else is waiting; but on a machine with 2 CPUs the server runs one worker by default, so someone reading very large notes, or sending heavy writes, one after another can make everyone else wait up to a job's time limit each time — three times `PARSE_TIMEOUT_MS` for reading or writing a note (30 seconds by default), four times for a file sent back over bulk transfer. A job that waits longer than `PARSE_QUEUE_WAIT_MS` (by default 4 × `PARSE_TIMEOUT_MS` + 5 seconds, 45 seconds, so that waiting behind one job running to its limit is enough) answers `server_busy` and writes nothing; behind two such jobs it can still run out. Each job counts against the sender's read or write budgets, but those budgets allow enough of them to keep one worker busy. At most 100 jobs wait at once; more answer `server_busy` straight away. Notes under 16 KiB don't wait for parse workers. Live editing in the browser doesn't use them.
- **Putting a large write into a note, and reading a large note back, still hold up the server briefly.** Reading the written Markdown, putting it into the note and turning a note back into Markdown happen in parse workers, but some linear work on the note's stored structure stays in the server's own thread: encoding a note someone has open to hand it to a worker (about 5.4 MB stored: about 14 ms typed straight through, about 173 ms heavily edited), putting the result into the open note, and saving the note. Measured on Linux: with about 245 KB in one paragraph of `<br>`-separated words or of alternating bold and italic, a write held the server up for about 0.2–0.45 seconds (before parse workers did this, 0.6–2.1 seconds); reading a note of 1 MB, or one paragraph of 49 000 such words, back about 0.01–0.15 seconds (before, 1.2–5.3 seconds). Applying a note version, and the one-time link conversion after upgrading, still turn the note around in the server's own thread. Text with a very long run of spaces (which only typing in the editor can make) takes time growing with the square of that run to read back: 100 000 spaces in one text took about 2.2 seconds — now in a worker, so it can run into the time limit below (#398).
- **A write can be refused with `server_busy` when someone keeps changing the same section back and forth.** A write from MCP, the API or bulk transfer, and a revert, is worked out in a parse worker against the note as it is at that moment. Changes others make meanwhile to other parts of the note don't matter — the write is merged with them — but if they touch the section the write's `if_match` names (for a whole-note `if_match`, or a file sent back, any part of the note), the write is worked out again, and a changed section then answers `409`. Only when the section was touched but ends up the same, three times in a row, is the write refused with `503 server_busy` (`Retry-After: 5`) and nothing written. A file sent back over bulk transfer needs the whole note unchanged, so steady typing anywhere in a large note can overtake it three times; try again when the typing stops (#398).
- **In the browser the note size limit is only a soft check, and a note can end up somewhat over it.** The server doesn't check live edits from the browser at all. The web editor checks before pasting, dropping content in or applying an AI action's result, using an estimate of how much the note will grow — the text of what is being added plus a fixed amount for each paragraph, list item, table cell and other element, for each piece of formatting (with a link's address) and for long attributes such as a diagram's source, minus what it replaces. Measured against the real growth, the estimate came out between about 0.94 and 1.15 times it for every shape tried (paragraphs, flat and nested lists, checklists, tables, long text, Chinese text, text with formatting or links on most words, pasted code, diagrams), so near the limit a paste can be let through that takes the note a little over it, or refused when it would just have fit. Replacing content with the same content is estimated as no growth, but still grows the stored state a little (the collaborative document's bookkeeping, a few percent of what was replaced), so that is bounded too. Typing isn't checked, so a note can also grow past the limit a keystroke at a time, and undoing a deletion (Ctrl+Z) can put content back. Someone with edit rights who writes their own collaboration client instead of using the editor bypasses the check entirely. Writes over MCP, the API and bulk transfer, reverts, applying a version and copying are checked exactly by the server (`413 note_too_large`).
- **A note over its size limit can still grow a little through MCP, the API and bulk transfer.** Over the limit, a write is judged by what it leaves behind: it is refused if the content still in the note grows, or if the note's stored state grows by more than 4 KiB in that one write. Replacing content with the same amount or less is accepted, but it still leaves bookkeeping behind in the collaborative document — about 40 bytes per replacement, measured — so a note over the limit that keeps being reworded grows by that much each time; deleting content shrinks it again. Formatting text, or changing an attribute to a longer value, adds content and is refused on a note over the limit.
- **The note size limit counts the note's stored state, not its text.** The stored state also holds formatting and the structure collaborative editing needs, so a note is larger than its text, and one whose text was inserted here and there (or by several people at once) is larger than the same text typed straight through — measured, a character inserted in the middle of existing text takes about 23 bytes, one typed at the end about 1. Deleting text frees almost all of its space. There is no way yet to see a note's size in the app; the server logs, when it starts, the ids of notes already over the limit.
- **A tab that is already open can check uploads and note size against an old limit.** The editor reads the upload size limit and the note size limit when the page loads its site settings, and checks a file before sending it, and a paste or a drop before adding it, against that value. After a site admin changes either limit, a tab that is already open keeps the old one until it reads the settings again, which usually happens when you switch back to it; reload the tab to be sure. Meanwhile it can refuse a file the new limit would take, or send one the new limit refuses (the server checks uploads against the limit in force when the request arrives and answers `413 file_too_large`); and it can refuse a paste or drop the new note size limit would take, or let through one it would refuse, since the server doesn't check the browser's edits (see "In the browser the note size limit is only a soft check" above).
- **Shortening the web sign-in period doesn't sign anyone out.** The period set in Site admin → Sign-in → Session and tokens is read when someone signs in (and when a changed, newly added or reset password starts a new sign-in), and that sign-in keeps the end time it was given; only sign-ins from then on use the new period.
- **Changes to the site limits are not passed on between server instances.** The values are kept in memory and read from the database when the server starts, so with more than one `app` process (not supported, see the [single-instance warning](./self-hosting.md#single-instance-warning)) another process uses the old values until it is restarted, and when two processes change limits at the same time, the old value recorded in the list of changes can be out of date.
- **Lowering a rate limit, or raising it again, doesn't reset anyone's count.** The windows already open keep their counts: someone who has already used as many requests as the new, lower number, or more is refused until their window ends. Requests that are refused are counted too, so after a limit is raised, someone who kept retrying while they were blocked may already be over the new number and has to wait for the window to end as well.
- **Changing the memory per parse worker replaces every worker.** The replacement workers take about 0.5–0.7 seconds to become ready, and jobs wait in line meanwhile. A busy worker finishes its job before it stops, so for a short while the old workers and the new ones exist together and use more memory than the formula in [Self-hosting](./self-hosting.md#choosing-the-values) allows for. If a worker had failed to start and the server was waiting before it tried again when the limit is changed, a healthy worker is replaced in that state too: there can then be no parse worker for up to about 30 seconds, and a job that waits longer than the longest wait for a worker (45 seconds by default) answers `503 server_busy`.
- **A very large note can't be read over MCP or the API, or downloaded.** Turning a note into Markdown may take three times `PARSE_TIMEOUT_MS` (30 seconds by default) and as much memory as a parse worker has (`PARSE_WORKER_MEMORY_MB`, 1024 MiB by default). A note that needs more answers `500 too_complex` (MCP: the tool error `too_complex`), and a bulk download leaves it out, listed as `skipped` with `reason: "failed"`. Measured on Linux, a note of 10 MB of ordinary text took about 10 seconds; one dense with formatting gets there sooner. It still opens in the web app. Raise `PARSE_TIMEOUT_MS` (the Site limits page only goes down from 10 seconds) or the memory per worker (`PARSE_WORKER_MEMORY_MB`, or Site admin → Site limits) to read such notes (see [Self-hosting](./self-hosting.md#parse-workers)) (#398).
- **Most `429` responses have no `Retry-After` header or wait time at all.** The exceptions: the `429 too_many_attempts` of `POST /api/auth/login` and `POST /api/auth/oidc/pending/confirm` includes a wait time, but in the JSON body (`retryAfterMs`), not a standard `Retry-After` header; every `429` of the four bulk transfer endpoints under `/api/transfer/` carries `Retry-After`; and so does the `429` that `POST /api/notes/:id/uploads` and `GET /api/uploads/:id` answer once invalid Bearer attempts from an IP are used up. The other rate-limited endpoints — `POST /api/notes/:id/collab-token`, slug changes via `PATCH /api/notes/:id`, `POST /api/notes/:id/uploads`, `POST /api/notes/:id/copy`, `GET /api/notes/:id/content`, `GET` and `POST /api/notes/:id/edits` and `POST /api/notes/:id/edits/:editId/revert`, `POST /api/notes`, `GET /api/search`, the public-link management and public share endpoints (#72), username renames via `PATCH /api/auth/profile`, the API-token limits (per-user token reads and writes, invalid Bearer attempts per IP on the endpoints that take an API token, and `POST /api/auth/tokens`), `POST /api/auth/oidc/pending/prove/:providerId`, `POST /api/auth/oidc/link/:providerId`, `POST /api/auth/register`, and the OAuth endpoints (`POST /oauth/register` 30/hour, `GET /oauth/authorize` 30/min, `POST /oauth/token` 60/min, all per IP) — return a bare `429` with no timing information at all (the OAuth ones as `{error:"invalid_request"}` or plain text); callers must back off and retry at their own discretion.
- **Setting `POSTGRES_PASSWORD` after the first `docker compose up` has no effect.** Postgres only applies that environment variable while initializing a brand-new (empty) data directory. If the `db` named volume already exists, changing `POSTGRES_PASSWORD` in `.env` and restarting does nothing — either run `docker compose exec db psql -U knotebook -c "ALTER USER knotebook WITH PASSWORD '...'"` and update `DATABASE_URL` in `.env` to match, or `docker compose down -v` to discard the volume and reinitialize from scratch (this deletes *all* named volumes, not just `db` — it takes the `uploads` volume with it too, so every note and every uploaded image is gone).
- **`ADMIN_EMAIL`/`ADMIN_PASSWORD` only take effect on first initialization** — the same "only applies while there's nothing there yet" shape as `POSTGRES_PASSWORD` above. Once the instance has been initialized (which only ever happens via these variables — see [Self-hosting](./self-hosting.md)), leaving them set in `.env` and restarting the `app` container does nothing: no new account is created, no error, no warning. There's no supported way to use them to change an existing account after the fact — see the [Quickstart](../README.md#quickstart-10-minutes) note above for how they behave on a genuinely empty database.
- **Switching identity providers means linking each person again.** Sign-in identities are keyed on the identity provider's issuer and subject (`sub`), not on email. When people start signing in through a different identity provider, an account whose email matches is not taken over automatically: each person is asked to prove the account is theirs — with its password, or by signing in with a sign-in service already linked to it — before the new identity is linked. Someone whose email doesn't match any account gets a new, separate account while that sign-in service has **Creates accounts** on (otherwise the sign-in is refused). Someone whose account has no password and whose only linked sign-in service has been turned off can't do that alone; the site administrator has to help.
- **No RP-initiated logout.** Signing out of Knotebook only ends Knotebook's own session in that browser — it doesn't log you out of the identity provider. If your IdP keeps its own persistent session (or you have SSO across other apps), clicking "Sign in with …" again can sign you straight back in without re-entering credentials there. That's expected OIDC behavior, not a bug in Knotebook.
- **OIDC discovery is cached until the sign-in service's settings change or the server restarts.** The IdP's configuration (endpoints and supported algorithms; its signing keys are re-fetched on their own) is fetched once via OpenID Connect Discovery and reused until one of that sign-in service's settings is changed in Site admin (turning it off or on counts) or the `app` container restarts — so after changing the IdP itself, turn the service off and on again or restart; see [Sign-in providers](./self-hosting.md#sign-in-providers).
- **The OIDC login/callback endpoints are rate-limited per-IP and in-memory.** `GET /api/auth/oidc/login` and `GET /api/auth/oidc/callback` (and their per-provider forms, `…/login/<provider id>` and `…/callback/<provider id>`, which share the same two limiters) each get their own 30-requests-per-minute-per-IP limiter — like the other in-memory limiters above, they reset on restart and can't be shared across instances. Because the two quotas are separate, an exhausted *callback* quota only shows up at the end of the flow: the sign-in starts normally, the user authenticates at the identity provider, and the failure appears when they come back. How trustworthy the per-IP dimension is depends on your `TRUST_PROXY` setting — see [Deployment prerequisites](./self-hosting.md#deployment-prerequisites). Starting "Confirm by signing in with …" on the account-linking page counts against the same per-IP login limit, and a wrong password typed on the linking page counts against the same lockout as the login form for that email. Starting **Link** in **Settings → Sign-in** counts against that same per-IP login limit too.
- **HS256-only identity providers aren't supported — and how that surfaces depends on what they advertise.** Signing-algorithm support is checked in two stages: if the IdP's own discovery metadata (`id_token_signing_alg_values_supported`) lists algorithms but none of them is RS/ES/PS-prefixed (an empty list counts as none, and so does a list of only EdDSA), the sign-in service is treated as unavailable before login even starts (`oidc_unavailable`); if the metadata claims asymmetric support but the ID token Knotebook actually receives back is signed some other way (e.g. HS256) — a mismatch between what the IdP declares and what it does — signature verification fails during the code exchange instead (`oidc_exchange_failed`). See [Sign-in providers](./self-hosting.md#sign-in-providers) for what to configure on the IdP side.
- **Linking a password account to SSO doesn't invalidate the original password.** After linking, both password login (while the site allows password sign-in) and SSO login work for that account going forward. There's currently no way to disable password login for a single SSO-linked account. A password the account holder no longer remembers can be replaced only through an admin's reset link (see "Resetting someone else's password" above); there is no self-service way.
- **Signing in returns you to the page you were trying to open — unless you have to change your password first.** When you land on the login page because a page needed a session, Knotebook remembers where you were headed and sends you back there after you sign in, over both password and SSO login. The one gap is a forced password change: an account flagged to change its password (such as one an admin created with a temporary password in an older version) is routed to the change-password page, and after changing it they land on the home page rather than the page they originally asked for. Signing out, and a session that expires while you have a note open, also drop the destination on purpose — the first because leaving is the point, the second because the note page navigates away before the guard runs.
- **Only same-site page paths are honoured as a return target.** The remembered destination has to be a path on this instance that maps to an actual page, at most 2048 characters, made of plain printable ASCII — non-ASCII characters have to arrive percent-encoded, which is what a browser sends anyway. A full URL to another site, an `/api`, `/oauth` or `/.well-known` path, the login page itself, the registration page or the account-linking page, or anything carrying a control character is silently ignored and you land on the home page instead. The same rule is applied twice on the SSO route: once when the destination is stored and again when you come back from the identity provider, so tightening the rule never resurrects a destination that was remembered under an older one.
- **Starting SSO sign-in in two browser tabs at the same time can fail both of them.** Both tabs share the same OIDC state cookie (scoped to the browser, not the tab); starting a second "Sign in with …" before the first completes overwrites that cookie, and the callback route clears whatever cookie it finds on the way in — so either tab's callback can land after the other has already consumed/cleared it and fail with `oidc_state_mismatch`, including the one you started first. Sign in via SSO from one tab at a time.
- **The account-linking page tells someone that an email has an account.** When a person signs in through an identity provider whose email matches an existing account, the linking page — or the error shown when that account can't be linked — confirms that this email has an account here, the same thing a "this email is already in use" message would reveal. The registration form does exactly that: `POST /api/auth/register` answers `409 email_taken` when the email already has an account, so anyone can use it to check whether an email is registered here (each IP gets 10 attempts per hour, in memory like the other limiters above).
- **Adding someone to a group, or sharing a note with them, tells you whether their email has an account.** `PUT /api/groups/:id/members` and `PUT /api/notes/:id/shares` answer `404 user_not_found` when no account has that email, and neither has a per-person or per-IP limit. Anyone signed in can create a group and add people to it, so anyone signed in can check as many emails as they like this way; adding several people at once on a group's **Members** page shows **No account with this email** for each such address, with no limit on how many at a time. It isn't silent: an email that does have an account is really added to the group (or the note is really shared with it), and that person — unless their account is disabled, since disabled accounts can be added too — sees the group in their sidebar (or the note under **Shared with me**).
- **The account-linking page shows which ways the account can be proved.** Anyone holding an identity-provider account with the same email sees whether the Knotebook account has a password and the names of the enabled sign-in services linked to it (leaving out one for the identity provider they just used). It does not show whether the account is disabled, or whether it is already linked to another identity at the same identity provider — those only come out after the person has proved the account is theirs.
- **An account with no password can't be linked from the outside when none of its sign-in services can be used to prove it.** That is the case when every sign-in service linked to it is turned off, or when the only one left is for the same identity provider the person just signed in with (for example, an account at that identity provider that was recreated and now has a different subject). The person then has no way to prove the account is theirs, and they get an error asking them to contact the site administrator.
- **"Must change password" is cleared only by changing the password.** An account an admin created with a temporary password in **Site admin → Users** (no longer possible; invitations replaced it) keeps asking for a new password even if its owner signs in with SSO or links SSO to it. This changed in v0.6: before, signing in with SSO cleared it.
- **The identity provider's `email_verified` claim isn't used.** An email the identity provider marks as unverified can still create an account on first sign-in, and identity providers that don't send the claim at all work normally. Linking a new identity to an existing account always needs that account's password or a sign-in service already linked to it.
- **Someone can take a colleague's email and receive what is shared with it.** Registering with a password, accepting an invitation and a first sign-in through an identity provider all create an account with the email given — typed on the registration page or the invitation page, or returned by the identity provider — without checking that the person owns it. If no account has that email yet, whoever registers it first (or uses an invitation link with it) holds the Knotebook account for it, and notes shared with that email, or that email added to a group, then reach them. The site administrator can only disable such an account after the fact; accounts can't be deleted (see "Admins cannot delete user accounts" above).
- **Turning a sign-in service off doesn't sign anyone out.** People who signed in through it keep their session until it expires, and their API tokens and connected apps keep working (a token created without an expiry keeps working until it's revoked). Deleting the service doesn't change that either. To cut someone off right away, disable their account in **Site admin → Users** — that stops their sessions and tokens too.
- **A sign-in service that creates accounts lets in anyone its identity provider lets in.** While **Creates accounts** is on for a sign-in service that anyone can get an account at (Google, gitlab.com), anyone with an account there can create a Knotebook account through it, as long as their email has no account here yet. Keep such a service's **Creates accounts** off and invite people from **Site admin → Users** instead (this needs password sign-in on), or only turn it on for an identity provider that only your people can sign in to.
- **Registering with a password is on by default, upgraded sites included.** With the defaults (**Allow self-registration with a password** and password sign-in both on), anyone who can reach the site can create an account at `/register`. Accounts created this way can use the AI quick actions the site has set up — the site pays for that usage and its quota — and take up upload space; only site administrators can manage the AI providers' API keys, and Site admin never shows a key (only whether one is set). A sign-in service's **Creates accounts** starts off when the service is added (a service imported from `OIDC_*` by v0.6 to v0.15 took over the value of **Allow self-registration with a password** at the moment of the import), and when upgrading, each existing service took over the registration setting the site had before. Together with the email point above, turn **Allow self-registration with a password** off in **Site admin → Sign-in** if you don't need it.
- **Linking a sign-in service from Settings can end on the login page.** If, while you're on the identity provider, that sign-in service is turned off or deleted or the sign-in cookie expires, or if the per-IP limit on coming back from identity providers is hit, you come back to the login page with an error rather than to Settings — you're still signed in.
- **Adding a password doesn't ask you to sign in again, and suspending someone at the identity provider doesn't revoke their password or API tokens.** While password sign-in is on, anyone holding a signed-in session can add a password to an account that has none, the same as creating an API token. When someone leaves, disable their account in **Site admin → Users**: suspending them only at the identity provider leaves a password they added (usable whenever password sign-in is on) and their API tokens working.
- **With password sign-in turned off, the identity provider is the only way in.** If it's down, its client secret expires, or someone is suspended there, they can't sign in to Knotebook. Recovering the whole site needs access to the deployment's settings and a restart (`PASSWORD_LOGIN_FORCE_ENABLE`, see [SSO-only sign-in](./self-hosting.md#sso-only-sign-in)); while that variable is `true`, anyone can sign in with a password, not only admins, and while **Allow self-registration with a password** is on, anyone can also register with a password again — remove it and restart as soon as you're done.
- **Turning password sign-in off doesn't sign anyone out.** Existing sessions, API tokens, connected apps and MCP clients keep working. To cut someone off right away, disable their account in **Site admin → Users**.
- **While password sign-in is off, the account-linking page still accepts passwords.** That is how password users move over to SSO on their own, so guessing a password still has one way in. Wrong passwords there still count toward the lockout for that email and address — the same counters the login form uses while password sign-in is on — and the guesser first needs an identity-provider account with the same email.
- **Turning password sign-in off doesn't remove anyone's password.** Turning it back on makes every password work again immediately — and so does setting `PASSWORD_LOGIN_FORCE_ENABLE=true`, once the server has restarted — including old ones nobody changed.
- **A password account whose email differs from its identity-provider email can't move over on its own once password sign-in is off — unless its owner is still signed in.** The account-linking page only appears on a first sign-in with the same email, and admins can't link a sign-in for someone else. Someone still signed in from before can link one from **Settings → Sign-in** (turning password sign-in off doesn't sign anyone out); otherwise turn password sign-in back on for a while so they can sign in and do that.
- **The check when turning password sign-in off only looks at you.** It requires at least one sign-in service that is turned on and a linked one for the admin making the change. Other admins aren't checked and may be unable to sign in afterwards.
- **Test connection can't check the client ID or secret.** Apart from noting a missing client ID and checking that the saved secret can still be decrypted, it only reads the identity provider's published configuration; it never exchanges a code, so a wrong client ID or secret first shows up when someone tries to sign in — as an error on the identity provider's page or as `oidc_exchange_failed` when they come back.
- **An administrator can still obtain a sign-in service's client secret, just not silently.** Knotebook sends the client secret to the token endpoint that the issuer's configuration names, so changing a service's issuer clears its saved secret and turns it off — a secret entered for one identity provider can't be redirected to another by editing the service. What remains is the slower path: an admin points a service at an issuer they control and waits for someone to enter the secret again. That leaves traces — the text under the service's row says it has no client secret, and the server logs `登入服務的 issuer 被寫入` with the account that did it and the old and new issuer — but nothing prevents it. **The log line's `to` and `hasSecretAfter` fields come from the write itself and are the trustworthy part; `from` is read separately and can lag under concurrent edits.** Separately, **Test connection** and **Check issuer** let an admin make the server send a request to any address it can reach; apart from the issuer a valid discovery document reports and whether it lists `client_secret_post` (shown as a warning), the response isn't shown, but whether it succeeded is. **Administrator is the highest privilege level in Knotebook.**
- **The affected-account counts can be off when a sign-in service has never connected.** Linked sign-ins are matched by the issuer the identity provider reports, which can differ from the one typed in (for example by a trailing `/`). Until Knotebook has read a service's discovery document successfully — at the start of a sign-in through it, or with **Test connection** — it only has the typed value (and changing the issuer drops what it had recorded). The service imported from `OIDC_*` may already have one recorded at import, inferred from existing sign-ins. So the counts shown before turning a service off may be too low when that service has never connected, and the "no other way to sign in" count may be too high when another service that would still let those people in has never connected. Press **Test connection** first.
- **Pasting a right-click-copied image is blocked.** Copying an image from a web page or another app with your browser/OS's "Copy Image" and pasting it into the editor gets intercepted with a "please save the file first, then drag it in" toast, even though it looks like a plain image paste. The clipboard in that case carries an HTML representation alongside the image file, and the guard treats "file plus a usable text/HTML representation" as ambiguous and blocks it outright rather than guessing which one you meant. Work around it by saving the image to disk first and dragging the file in, or by using your OS's "Copy image as file" if it has one. Dragging an image straight off a web page (no save step) embeds it by its external URL instead of uploading it — see "External images embedded by URL" below. The same guard also blocks pasting a file copied from a file manager (e.g. Windows Explorer) whenever the clipboard additionally carries a plain-text filename representation, for the same first-match-wins reason.
- **Uploaded file cleanup is not automatic.** Orphaned blobs on disk (a DB row was never created, or was deleted, but the file wasn't removed) can accumulate from: an `INSERT` failing after the file was already written (best-effort unlink, which can itself fail); the best-effort post-commit unlink failing when a note is permanently deleted from the trash or cleared out after the retention period, and the same for deleting a group with **Delete everything**; a note copy that fails after it has already written some attachment files, when the best-effort unlink of those fails too (#175); and a known upstream BlockNote behavior where, during a multi-file drag-and-drop under live collaboration, a block can be removed (by undo, a manual delete, or a remote collaborator) while its upload is still in flight — the editor's own `updateBlock` then throws, aborting the rest of that batch and leaving any already-uploaded files in that batch orphaned. None of this corrupts data; it just wastes disk space. Automatic orphan cleanup is planned for a future release. Such files don't count toward storage quotas.
- **An image block copied and pasted into another note still points at the note it was originally uploaded to, not the note displaying it.** (Copying a whole note — **Copy to… → My notes**, or copying it into a group — is different: the copy gets its own copies of the note's own attachments.) BlockNote's image blocks store a plain `/api/uploads/:id` URL, and copying that block into a different note copies the URL, not the file. Two consequences: permanently deleting the *source* note deletes the blob too, permanently breaking the image everywhere else it's been copied, with no way to recover it (while the source note is only in the trash, the image is broken until it's restored); and a reader who can see the note the image is copied into, but not the note it was originally uploaded to, gets `403` trying to load it — access is checked against the upload's owning note, not wherever it's currently displayed.
- **Storage quotas count attachments only, per space.** Every personal space and group has a storage plan (with a limit, or none); a limit counts attachment bytes only (not note text, and not files left on disk without a database row — see "Uploaded file cleanup is not automatic"). Someone a personal note is shared with as an editor uploads into the owner's space: they can fill it, after which the owner can't add attachments either, until a site admin gives a larger plan or space is freed — by permanently deleting notes that have attachments (moving them to the trash doesn't free anything), or moving them into a group. The per-user upload rate limit still applies on top — 120 files per 10 minutes, where a copy counts one per attachment of the note it copies (a note with more than 120 is capped at one window's worth; an attachment whose file is missing still counts).
- **Removing an image from a note doesn't free its space.** Every file uploaded to a note (or copied into it with the note) stays recorded against that note, and keeps counting toward its space, until the note itself is permanently deleted (moving it to the trash doesn't free it) — whether or not the note's content still shows it. So a note whose images have all been removed still carries their size: moving it into a group adds that size to the group (and can be refused when the group is full), and only permanently deleting the note frees that size for good (moving a personal note into a group frees it from the personal space by handing it to the group).
- **Storage usage is the recorded size, not disk usage.** A space's usage adds up the sizes recorded for its attachments; it doesn't look at the disk.
- **A group's By member can credit the wrong person for files from before v0.10.** It counts each file for whoever brought it into the group — uploaded it into one of the group's notes, or moved or copied its note in — but nothing recorded who moved a note before v0.10, so the files of notes moved into a group before the upgrade still count for the people who uploaded them. (Copies made before the upgrade aren't affected: a copy already recorded the person who copied it.) If the server was rolled back to v0.9 for a while, files uploaded or copied while v0.9 ran count under **Deleted user** after upgrading again (until their note is next moved into a group, which counts every file of the note for the person who moves it), and notes moved into a group while v0.9 ran keep counting for whoever they counted for before (usually the people who uploaded them).
- **An image block copied from another note into yours doesn't count against your space.** It still points at the other note's attachment, which stays in that note's space (see "An image block copied and pasted into another note…" above).
- **Adding attachments to the same space happens one at a time.** Uploads, copies, moves and transfers that add attachments to one space wait for each other; while a large group is being transferred to someone, their own uploads can wait, and after 5 seconds they are answered `409 server_busy` (retry).
- **A plan change can race an upload already in progress.** An upload that read the old limit a moment before an admin changed it is judged by the old limit.
- **Copying into a full space can be refused even when nothing would be copied.** Before copying anything, a copy that has attachments to copy (the note's own attachments that its content links to as `/api/uploads/<id>`) checks whether the place it goes into is already at or over its limit, and if so answers `409 storage_quota_exceeded`. That check counts the note's attachments as recorded in the database, so a note whose attachment files are all missing on the server — which the copy would skip — is still refused when the space is full.
- **Anyone who can read a note can re-store its attachments without having uploaded them.** Copying a note counts against the copier's upload limit, so copying attachments you uploaded yourself costs about what uploading them did. But anyone who can read a note — a viewer it's shared with, or a group member whose role can only read — can copy it, storing new copies of files other people uploaded. Per user that is capped at 120 copied files every 10 minutes, with one exception: a note with more than 120 attachments is copied whole, as long as none of the current 10-minute window's upload limit has been used, and the copy then uses up that window. Each copy also counts against the limit of 30 writes per minute. Repeatedly copying a note with many attachments can therefore use up storage faster than uploading could (#175). Copied attachments count against the copier's personal space (or the group copied into), so re-storing someone else's attachments uses up your own storage quota.
- **Copying many notes at once from the sidebar stops after about 30 in a minute — sooner if you've made other writes in that minute, or, when the notes have attachments, copied or uploaded many attachments in the last 10 minutes.** Each copy counts against the per-person limit of 30 writes a minute, which is shared with writes through the API and MCP (edits, creating a note with content, and MCP's `delete_note` and `restore_note`), with reverting an AI edit (in the browser too), and with saving, applying, renaming and deleting versions; copying a note's attachments also counts, one per attachment, against the upload limit of 120 every 10 minutes, which uploading files shares. A larger selection stops partway with "Too many requests. Please slow down."; the notes already copied stay copied, and the one it stopped at and those after it stay ticked, so you can try again once the limit has passed — within a minute for writes, up to 10 minutes for attachments. Moving notes into a group or to the trash isn't limited this way, and neither is anything done with folders, which has its own, much larger limit (see [Folders](#folders)).
- **While several notes are being moved into a group or copied from the sidebar, or folders deleted or removed, the confirmation can't be closed — if the server stops answering, reload the page.** It stays open until the last request comes back, and the web app gives a request no time limit; the **Move to…** and **Copy to…** dialogs for a single note behave the same way. A request already sent may still have gone through, so check the notes after reloading.
- **A note's backlinks reflect whoever most recently wrote its outgoing link set, filtered to what *they* could see at the time.** The link index is replaced as a whole (not a diff) — by the editing client when a browser saves the note, and by the server from the content it wrote when an API or MCP write, an applied version, a reverted AI edit or a copy lands — and each target is filtered through the read access of whoever made that write. If an editor with narrower access than a previous editor saves the note, links to notes they can't see drop out of the index — and reappear if someone with broader access edits it again. This is by design (the index follows whoever wrote last, not a ground truth independent of who's editing), not a bug, but it means backlinks can be surprising in heavily-shared notebooks. The one-time link conversion after upgrading to 0.11 only adds links, filtered with the note owner's access (in a group note: notes in the same group) (#106, #269).
- **`GET /api/notes/:id/backlinks` is capped at 200 results (`MAX_BACKLINKS`), most-recently-updated first, with no pagination.** A note linked from more than 200 others silently shows only the newest 200 sources.
- **`POST /api/notes/:id/links` has no rate limit.** Unlike most other write endpoints, this one is deliberately not throttled — its per-request cost is already bounded by the single batched authorization query and the 1000-target cap (`MAX_LINK_TARGETS`), and it's normally called by a debounced background sync, not directly by user action.
- **The note list has no pagination**, and it's the only source used for wikilink autocomplete candidates, for resolving a link to a note's live title, and for converting a pasted, dragged-in or typed link to a note at once (other addresses are looked up on the server) — both effectively cap out at however many notes the list endpoint returns before something has to change. Knotebook's full-text search (the sidebar's search, `GET /api/search` and `search_notes` over MCP, #93) doesn't feed either of them yet; a server-side lookup for wikilink candidates and titles is a future release.
- **External images embedded by URL are hotlinked, not proxied.** BlockNote's image block also accepts a plain external URL (not just an uploaded file) — Knotebook renders that URL directly in the reader's browser rather than fetching and re-serving it, so the external host sees the reader's IP address on every view. It does not learn which note that is: pages are sent with `Referrer-Policy: no-referrer` (#101). Treat this the same as embedding any external image in any other tool.
- **An assistant tricked by a prompt injection can send your notes' images off-site.** `create_transfer_token` puts a short-lived token in the assistant's context, its shell commands and the client's transcript. An assistant steered by instructions hidden in content it reads (for example a note someone shared with you) can ask for a download token for every note it can read and send the images' bytes anywhere — a path that did not exist before, when an API token or app credential could not fetch a note's images at all. It can also fill notes it can edit with uploads: at most 5 unused upload tokens per credential at once, and up to the 60-per-10-minutes token write budget — with the default limits, about 600 MiB of 10 MiB files every 10 minutes (a site admin can lower the budget and the upload size limit). Uploads work in the other direction too: it can put image files from your machine, such as screenshots, into a note other people can read (for example a group note, or one someone shared with you as an editor) — through your Knotebook address, which you have probably let it reach with `curl`. Short lifetimes, one note per token, keeping the token out of URLs and Claude Code's prompt before it runs a shell command limit this; none of them prevents it (#200).
- **An assistant tricked by a prompt injection can move your personal notes into a group, or copy notes into one.** This needs the `notes:move` scope: an MCP client whose token or app credential has it gets `move_note_to_group`, and can pass `group_id` to `copy_note`. A credential only gets `notes:move` when you ask for it — by turning on **Move or copy notes into groups** when you create a token or later on its row in **Settings → Authorization**, by ticking it on the consent page, or through `POST`/`PATCH /api/auth/tokens` — and no credential gets it automatically, including those issued before it existed. Moving hands a private note to everyone in the group and removes its per-person shares, public link and version history, which don't come back, and a single note can't be moved back out. Copying can put the content of a note someone shared with you into one of your groups, where the members who can read the group's notes see it. This is what **Move to…** and **Copy to…** do in the web app — the tools can also move or copy into a group where your role can create notes but not edit them — except that no one has to click. Mitigations: leave `notes:move` off unless a program really needs it — a credential without it doesn't have `move_note_to_group`, and its `copy_note` refuses `group_id` with `insufficient_scope`; Claude Code asks before a tool call unless you've allowed the tool or turned prompts off — that is not a defense. `notes:move` covers handing over an existing note and copying a whole note into a group, not writing into a group's notes: a `notes:write` credential can still put what it has read into a group's notes — for example with MCP's `create_note` (with `group_id`) and `edit_note`, or the REST `POST /api/notes` (with `groupId`) and `POST /api/notes/:id/edits` (#180, #239).
- **An assistant tricked by a prompt injection can move your notes to the trash.** This needs the `notes:delete` scope: an MCP client whose token or app credential has it gets `delete_note`, `restore_note` and `delete_folder`. A credential only gets `notes:delete` when you ask for it — by turning on **Move notes to the trash and restore them** when you create a token or later on its row in **Settings → Authorization**, by ticking it on the consent page, or through `POST`/`PATCH /api/auth/tokens` — and no credential gets it automatically, including those issued before it existed. It can move any note your account could move to the trash in the web app: your own personal notes, and the notes of groups where your role can delete notes. While a note is in the trash, someone who can restore it can bring it back, in the web app or with `restore_note`; but it is deleted permanently once it is due under the site's trash setting — the shorter the retention period, the less time there is to notice — unless the site is set not to empty the trash on its own, and someone can delete it permanently sooner in the web app. A group note can only be brought back by a member whose role can both edit and delete notes, so if your role can delete but not edit, neither you nor the assistant can see or restore the group notes it moved there. No MCP tool deletes a note permanently or empties the trash. Mitigations: leave `notes:delete` off unless a program really needs it — a credential without it doesn't have `delete_note`, `restore_note` or `delete_folder`; Claude Code asks before a tool call unless you've allowed the tool or turned prompts off — that is not a defense (#181).
- **An assistant tricked by a prompt injection can delete your folders, or rearrange them.** With the `notes:delete` scope, one `delete_folder` call deletes a folder and every folder inside it and moves every note in them that isn't in the trash yet to the trash, all at once — in your personal folders, and in a group's folders when your role there can edit and delete notes (in your folders for notes shared with you, it only removes the folders). Those notes can be brought back while they are in the trash, re-creating the folders on the way to each one, as in the entry above; folders that held no notes don't come back. Without `notes:delete`, a credential with `notes:write` can't delete folders, but it can create, rename and move them and file notes in them (`create_folder`, `rename_folder`, `move_folder`, `move_note_to_folder`) — and in a group where your role can edit notes, every member who can read the group's notes sees those folders, names included. Mitigations: leave `notes:delete` off unless a program really needs it, and `notes:write` too when it only has to read; Claude Code asks before a tool call unless you've allowed the tool or turned prompts off — that is not a defense.
- **`read_note_image` shows an image only if you can read the note it was uploaded to.** It needs read access to the `note_id` you pass and to the note the image belongs to — the same rule as fetching `/api/uploads/<id>` in the browser — so an image block copied from another note shows only if you can read that note. An image written with an external URL can't be read through it (#200).
- **An image uploaded but never written into a note is still an attachment.** An upload made with a transfer token that `edit_note` never references — or whose reference is later removed — keeps its file and its `uploads` row until the note it belongs to is permanently deleted, and its size counts toward that note's storage space (see "Removing an image from a note doesn't free its space"). A transfer-token upload that fails after the server has started reading the file (`413`, `415`, `400`, or a `409` — `storage_quota_exceeded` because the file doesn't fit, or `server_busy`) still uses the token up (#200).
- **The `curl` commands `create_transfer_token` and `open_notes_as_files` return are built from `PUBLIC_URL`.** If `PUBLIC_URL` doesn't match the address the assistant can reach, its uploads and downloads, and the files it sends back, go to the wrong place — the same family as "A wrong `PUBLIC_URL` makes MCP requests that carry an `Origin` header answer `403`" (#200).
- **On a plain-http `PUBLIC_URL` (for example a LAN deployment), transfer tokens cross the network unencrypted,** exactly as API tokens do there (#200).
- **An MCP connection made with the browser's sign-in session has no `create_transfer_token` or `open_notes_as_files`.** The tools need a token or app credential to tie their transfer tokens to; the web app uploads and shows images with its own session instead (#200).
- **Uploads have no CSRF check when the request carries no `Origin` header.** `POST /api/notes/:id/uploads` and the sign-in service icon upload (`PUT /api/admin/auth/providers/:id/icon`, which only admins get through to) reject a mismatched `Origin`, but a request with *no* `Origin` header at all is allowed through, matching how legitimate non-browser API clients behave. This is a deliberate trade-off documented for completeness, not an oversight.
- **Revoked upload access can still be served from a browser's cache.** `GET /api/uploads/:id` sets `Cache-Control: private, max-age=31536000, immutable` so a browser never re-checks it — appropriate for a URL that never changes content, but it also means a viewer whose share was just revoked can keep seeing an image they already loaded until that cache entry is evicted on its own, well past any revocation SLA the rest of the system aims for.
- **The uploads directory is a Docker named volume; if it isn't mounted, uploaded images vanish the next time the `app` container is rebuilt.** The default `docker-compose.yml` already mounts it (`uploads:/app/uploads`) — this only bites if you've customized the compose file and dropped that volume.
- **Only image blocks can upload; audio, video, and file blocks are embed-by-URL only.** The block editor's file panel gives image blocks both an Upload tab and an Embed tab, but audio/video/file blocks only ever get the Embed tab — there's no server-side upload path for them, only pasting in a URL to an already-hosted file.
- **AI quick-action requests are rate-limited to 30 per user per minute by default (a site admin can lower it), in-memory.** Like the other in-memory limiters above, this counter lives in process memory and resets whenever the `app` container restarts (and can't be shared across instances — see the [single-instance warning](./self-hosting.md#single-instance-warning)).
- **AI quick actions round-trip your note content through Markdown, which can lose fidelity.** The selected block(s) are serialized to Markdown before being sent to the AI provider, and the response is parsed back from Markdown into blocks; formatting that doesn't survive that round-trip cleanly (e.g. some nested structures or block-specific attributes) can be altered by running a quick action over it, independent of anything the AI itself changed.
- **Anthropic responses are capped at 4096 output tokens (`max_tokens`), with no error on truncation.** If a quick action's response hits that limit, Anthropic's API stops generating and Knotebook streams whatever was produced without surfacing an error — long outputs (e.g. "continue" on a lengthy selection) can be silently cut off mid-sentence. This is a fixed server-side constant today, not yet a configurable setting.
- **Pasting a plain-text file view (a `/raw/` URL) can be parsed as Markdown even when the file is source code.** A browser rendering a `text/plain` document wraps it in a bare `<pre>`, which carries no formatting of its own, so the clipboard cannot say whether that file was a Markdown document or a Dockerfile. BlockNote's Markdown detector then decides, and it fires on incidental syntax: an underscore pair such as `$SERVICE_NAME` or a backticked word inside a comment is enough. Once it treats the payload as Markdown, a leading `# comment` becomes a heading. A code snippet copied from a *documentation page* is not affected — that arrives as a `<pre><code>` block, which Knotebook honours as a code block — and neither is a copy out of VS Code, which tags the clipboard with the file's language. To paste a source file from a raw URL verbatim, insert a code block first and paste inside it: pasting inside a code block always inserts plain text.
- **Pasting a large amount of plain text into an ordinary paragraph is slow in the browser, and very large pastes never arrive.** Plain text pasted into the editor is read as Markdown, so lines that follow one another with no blank line between them (logs, chat transcripts, hard-wrapped text) become one paragraph with a line break at the end of each line, and the browser editor slows down more than proportionally with that shape. Measured on 2026-11-08 in headless Chromium: pasting 1 MB froze the tab for about 6 seconds and each keystroke afterwards took about 0.35 seconds; 2 MB froze it for about 26 seconds, then about 2.8 seconds per keystroke; at 3 MB the tab stopped responding and the text never reached the server. The same text pasted inside a code block takes about a second even at 10 MB. To paste a large block of text, create a code block first and paste into it, or separate the paragraphs with blank lines before pasting. Not planned for this release.
- **A collaboration client that sends ten messages the server doesn't recognize gets disconnected — including a hypothetical newer client version.** The server classifies incoming collaboration messages against the set its bundled library version actually handles and force-drops a connection after ten unrecognized ones (see [Troubleshooting](./self-hosting.md#troubleshooting)); this is the guard against a signed-in account flooding the log (#50). The ten-message grace absorbs incidental noise, but if a future client version ever introduces a *frequent* new message type, browsers running it against an older server would be disconnected and reconnect in a loop until the server is upgraded too. In practice the web client and server ship in one image, so the realistic skew — a stale browser tab across a deploy — runs an *older* client against a newer server and never sends unknown types.
- **"This looks like a failed chunk load" is decided by matching error messages.** When the note page's on-demand code chunk fails to load (#66), the automatic recover-by-reload path only fires if the error message matches a known wording: the phrasing Chromium, Firefox and Safari use for a failed dynamic import, Vite's own "Unable to preload CSS" message (what a failed load of this chunk actually produces most of the time, since the chunk ships with a CSS file that is fetched first), and the MIME-type error a `try_files`-style reverse proxy produces when it answers a missing asset with `index.html`. If a future browser or bundler version rewords any of these, nothing breaks — the failure just lands on the generic error screen with a retry button instead of reloading automatically. The trade-off fails toward the safe side (never toward a blank page or a reload loop), which is why message matching was accepted at all. Separately, the *nested* chunks the note page loads on demand after mounting (currently syntax highlighting) are outside this mechanism entirely: they fail as unhandled promise rejections, not render errors, so a deploy rotation can leave code blocks silently unhighlighted until the next full reload.
- **The note page auto-reloads at most once per successful load, per tab.** The reload that recovers from a deploy rotation (#66) spends a per-tab budget tracked in `sessionStorage`, re-armed only when the note chunk actually loads successfully — so a persistently broken deploy shows the error screen rather than reloading forever, and a tab whose storage is unavailable (some private-browsing modes) skips the automatic reload entirely and goes straight to the error screen with its retry button. Duplicating a tab copies the spent budget along with it; each tab re-arms on its own next successful load.
- **Scrollbars are deliberately low-contrast.** The scrollbar thumb's contrast against its track falls short of WCAG 1.4.11's 3:1 in every state but one (light: resting ≈2.1:1, hover ≈2.9:1; dark: resting ≈2.9–3.0:1) — a conscious visual-subtlety trade-off, not an oversight. The one state that clears the bar is dark-mode hover, at ≈3.9:1. Scroll affordance is still available through other cues: content gets visibly clipped at the viewport edge, and the thumb still occupies space in its track even where its contrast is hard to make out.
- **On a window narrower than 768px, hovering a block shows its handles partially clipped.** Below 768px the note text's side inset shrinks to 20px (so the text lines up with the title where horizontal space is scarce), and BlockNote's hover handles — the `+` and `⠿` that appear to the left of a block — live in that inset and are wider than it, so a mouse user on a narrow window sees them cut off by the card edge. Touch devices never show hover handles, and windows 768px and wider keep the full 70px inset, so the common cases are unaffected ([issue #115](https://github.com/sw-willie-wu/knotebook/issues/115)). Fixing the table-hover page scrollbar (#160) made the note's centered content wrapper the nearest positioned ancestor for these handles too, which widened the clipping: measured at a 390px window, the visible sliver went from about 33px (clipped by the window edge) down to about 20px (now clipped by the card edge) — the 13px difference is the card's 1px border plus its 12px page padding.
- **A public share page shows a snapshot that can lag the editors by a few seconds.** Anonymous readers get whatever the collaboration server last persisted — during active editing that's roughly the 2-second persistence debounce, up to 10 seconds under continuous typing, flushed when the last editor disconnects. Reloading fetches a fresh snapshot; live sync for anonymous readers is out of scope (see [Sharing](./sharing.md)).
- **A public share exposes the note's full CRDT state, not just its rendered text.** The public content endpoint returns the note's Yjs document as-is, which includes collaboration-internal structure (client ids, and — where the garbage collector hasn't reclaimed them yet — traces of edit history). GC is on, so deleted content is usually already gone, but "what the page displays" and "what the response contains" are not byte-for-byte the same thing. Treat making a note public as publishing the document, not a rendering of it.
- **Images an anonymous reader already loaded survive revoking the public link, in that reader's browser cache.** The public image endpoint uses the same `Cache-Control: private, max-age=31536000, immutable` as the logged-in one — the note *content* stops being served immediately on revoke (`no-store`), but an image blob that a browser has already cached can be re-displayed from that cache until it's evicted. Same trade-off as the revoked-member case above.
- **An image block copied and pasted from another note breaks on the public page.** A public token only authorizes blobs uploaded to the note it shares; an image block whose file was uploaded to a *different* note (copied over as a block) gets a 404 from the public image endpoint. That's the boundary working as intended — the alternative would let one public note leak any note's uploads — but the visible symptom is a broken image for anonymous readers of content that looks fine to members. Copying a whole note avoids this for the note's own attachments; images in it that point at another note's attachment still break (see "Copying a note doesn't rewrite every link to its attachments" below).
- **An image embedded by absolute same-origin URL breaks on the public page.** The public page rewrites uploaded images' relative `/api/uploads/:id` URLs to the public image endpoint that matches how the page was reached — the token form or, for a custom public URL, the `/<username>/<name>` form (#122) — but only that exact relative form: a block whose URL spells out the full origin (`https://your-host/api/uploads/...` — the Embed tab requires absolute URLs, so this is what pasting your own upload's address produces) is left alone, and anonymous readers get a 401 broken image. Upload images through the Upload tab and this never comes up.
- **A custom public URL reveals your username, and is meant to be guessable.** The point of `https://your-host/p/<username>/<name>` is that people can read and remember it — which also means anyone can *probe* it, and the URL itself discloses which account owns the note. Usernames default to the identity provider's `preferred_username` claim (for SSO sign-ups that carry one) or the local part of the account's email — so under corporate SSO they are often the internal account name. If either of those is a problem for a given note, share its token link and leave the custom URL unset — the token form discloses nothing (see [Sharing](./sharing.md)).
- **A note reachable both ways gets double the public read quota, and the throttle table grows accordingly.** The token form and the custom-URL form of the same note count reads in separate rate-limit entries (the keys deliberately differ so readers of one form never throttle the other), so the effective per-IP read quota for that note is twice what a single form gets. The two forms also double the number of live entries the fixed-size throttle table holds, which under heavy mixed traffic shortens how long any one entry effectively lasts (the table evicts oldest-first). Both are accepted: the quota exists to bound work per request source, not to meter a note's total readership.
- **Copying a note doesn't rewrite every link to its attachments.** A copy gets its own copy of an attachment only when the attachment belongs to the note being copied and a block's URL is exactly `/api/uploads/<id>`. In the copy, these still point at the original attachment, so access to them is still decided by the note that attachment belongs to (once that note is in the trash or permanently deleted the image breaks, and someone who can't read that note gets `403`): an image block pointing at **another** note's attachment (for example one copied and pasted from another note — see "An image block copied and pasted into another note…" above), an upload URL inside a text link, and an image whose URL is written out in full as `https://<host>/api/uploads/…`. An attachment whose file is already missing on the server is skipped, and the copy keeps its original URL (#175).
- **Copying a note with many attachments holds it briefly and doubles their disk usage.** The copy duplicates the attachment files it copies inside the same database transaction that creates it. While that runs, people can keep editing the original's content, and its title as long as that doesn't change its URL name — a title-only change goes through straight away. Renaming a note that has no custom URL name usually does change it (the automatic URL follows the title), so that save waits, as do deleting the note, moving it into a group, changing its URL name or deleting the group it's in, until the copy has finished. Apart from the upload limit (see "Anyone who can read a note can re-store its attachments…" above) and the storage quota of the place the copy goes into, nothing caps how many attachments one copy duplicates (#175).
- **Moving a note into a group, or deleting a group and giving its notes to an admin, drops the note's previous-name redirect.** Of its `/n/…` addresses, only the one the note had at the moment of the move forwards to its new group address, for a month; if you had changed its URL name before that, the older name stops working immediately (old-form `/notes/…` links keep working). Its per-person shares and its public link (including any custom public URL) are removed; someone whose role in the group can manage public links can turn on a new public link afterwards — an anonymous one, since a group note has no custom public URL. Deleting a group with **Give them to an admin** works the same way: each note's `/g/…` address at that moment forwards to its new `/n/…` address, for a month, while a URL name it had in the group before that stops working, and its public link is removed — the admin who now owns it can turn on a new one. In both cases a forwarding entry the note already had — say, from being moved into a group less than a month earlier — keeps working until its month is up (unless another note gets that address in the meantime), and leads to the note where it is now, for the people who can read it (#175).
- **Group notes can't be shared with individual people.** Access to a group note comes through the group: the Share dialog of a group note has no per-person sharing, and the per-person sharing endpoints answer `403` on a group note you can read. To let someone in, add them to the group — which also gives them every other note in it (#103, #175).
- **MCP clients don't list your groups.** `list_notes` and `search_notes` include the notes of every group you belong to, and each result's `owner` names the group, with its id; `edit_note` works on a group note when your role in that group can edit, and `create_note` with `group_id` creates a note in a group when your role there can create notes. No MCP tool lists your groups as such. `list_folders` lists the folders of every group whose notes your role can read, with the group's id and name in each folder's `space`, so a group that has folders shows up there even before it has notes; for a group with neither notes nor folders yet, you need its id from a signed-in browser session: it is in the address of the group's panel in the web app (the group's `⋮` menu in the sidebar → **Members & settings**, or **View members** if your role can't manage members), `/groups/<group id>`, and in what `GET /api/groups` returns. The REST move and copy endpoints still accept only a signed-in browser session; a token reaches the same operations through MCP's `move_note_to_group` and `copy_note` (#103, #175).
- **A group's last admin can't leave it or step down.** A group always keeps at least one admin: leaving, being removed, or being demoted as the only admin answers `409 last_admin`. Make someone else an admin first, or delete the group (its notes go to an admin you pick — you, if you're the only one — or are deleted with it) (#103, #175).
- **Disabling a group's only admin leaves the group without a working admin.** Disabling an account doesn't touch its group memberships, so unless another member's role can manage members (which lets them make anyone, themselves included, an Admin) or manage roles and the group (which lets them give their own role Manage members), nobody left in the group can rename it, manage its members or delete it. A site admin can fix it through the API — from a signed-in browser session, since the group endpoints don't accept API tokens, and with the group's id, which has to come from elsewhere because `GET /api/groups` lists only a site admin's own groups (a remaining member sees it in the address of the group's panel, `/groups/<group id>`, or of any of its notes, `/g/<group id>/…`; failing that, the database): promote another member to the group's Admin role (`PATCH /api/groups/:id/members/:userId` with `{roleId}` set to that role's id, from `GET /api/groups/:id/roles`), or, if nobody else is left, add someone with the group's Admin role (`PUT /api/groups/:id/members` with the Admin role's `roleId`) (#103, #175).
- **Site admins can manage a group they don't belong to only through the API.** On the group endpoints a site admin can manage every group — whether or not they're a member, and whatever their role in it is — but they still can't open its notes without being a member, the sidebar and **Settings → Groups** only show groups you're a member of, and opening another group's panel (`/groups/<id>`) shows a not-found message. Using the API takes a signed-in browser session (the group endpoints don't accept API tokens) and the group's id, which `GET /api/groups` won't list for a group you aren't in (#103).
- **The permissions that manage a group are a convenience, not a security boundary.** Someone whose role can manage members can make anyone — themselves included — an Admin, and someone whose role can manage roles and the group can give their own role Manage members. A role meant to "only rename the group" or "only add people" can't stop its holder from taking the rest; give either permission only to people you would trust as an Admin (see [Sharing](./sharing.md#notes-in-a-group)) (#175).
- **After someone's role or its permissions change, pages they already have open may update only the editor; menus and buttons can lag until the page reloads or its tab regains focus.** The note's content, its title and the connection badge follow the role the live-editing connection reports, so they turn read-only, or editable again, within a few seconds. The rest of the page — the **Delete** item in the `⋮` menu, the **Public link** switch in the Share dialog, the **+** next to the group's name in the sidebar — shows what that browser last loaded (the note's own controls also refresh when someone else's edit to the note arrives); only the browser of the person who made the change reloads it right away. Until the holder's page catches up, a control that is still showing can be refused by the server, and one they have just been given stays hidden. Reloading the page, or switching to another tab and back, brings it up to date. The groups offered under **Move to…** and **Copy to…** in a note's `⋮` menu lag the same way: the list is reloaded each time the menu opens, so it is out of date only while the menu stays open (and for a moment after it opens, while it still shows the list loaded earlier); moving or copying into a group where your role can no longer create notes is refused with "We couldn't find that group." This applies to any role change — changing someone's role on the group's member list as well as changing a role's permissions on the **Roles** page of the group's panel (#175).
- **On a group note, two people who can manage its public link can undo each other's link without noticing.** The Share dialog's **Public link** switch shows the state your browser last loaded. If someone else turned the link on after that and you then switch it on too, a new link is made and the one they may already have handed out stops working — switching the link on always makes a new one, the same as **Regenerate link** (#175).
- **If your role can't manage a group note's public link, the Share button and dialog don't tell you the note is public.** They show only the group's access, even when the note also has an anonymous public link; only people whose role can manage public links see it (#175).
- **Leaving a group while one of its notes is open doesn't close that note right away.** Your access ends immediately (see [Sharing](./sharing.md#notes-in-a-group)), but the open page only notices when its live-editing connection re-checks your access — which leaving triggers right away, and which takes at most a few seconds — and then says you no longer have access and takes you back to the home page — the same thing that happens when someone else removes you from the group. ⚠ Derived from reading the code; the case where you leave yourself is not confirmed end to end (#175).
- **Deleting a group with Delete everything can remove a note added to it a moment earlier without telling the people who have it open that it was deleted.** Before anything is deleted, everyone who has one of the group's notes open is taken out of it and told the note has been deleted. A note that is created, moved or copied into the group after that step, but before the deletion itself starts, is deleted along with the rest — but someone who has it open is told "You no longer have access to this note." within a few seconds instead, and what they type in those seconds isn't saved (#175).
- **When deleting a group with Delete everything fails, people who had its notes open may already have been told a note was deleted when it wasn't.** Before anything is deleted, everyone who has one of the group's notes open is taken out of it and told it has been deleted; if the deletion then fails — it was cancelled with "The server is busy" (see below), it ran into some other error, or another admin gave the group's notes to an admin a moment earlier — nothing is deleted, but those people have already seen that message. When it simply failed, the notes are still in the group and can be opened again (reloading the page is enough). When another admin gave the notes away first, they are now that admin's personal notes: the admin who received them can open them again, and everyone else has lost access to them either way (#175).
- **A request a former member had already sent when the group was deleted can still take effect on a note that now belongs to the admin who received it.** Whether a request is allowed is decided from the sender's role in the group when the request arrives, and the write doesn't check again who owns the note. So if the group is deleted with **Give them to an admin** while such a request is in flight, it can still go through on what is now the admin's personal note: deleting the note, editing it through the API or the MCP `edit_note` tool, writing the content of a note they had just created in the group (`POST /api/notes` with `groupId` and `content`, or the MCP `create_note` tool with `group_id` and `content`, first creates the note in the group and then writes its content, and if writing it fails, deletes the note again), reverting an AI edit, uploading an attachment to it — or, if the admin has just turned its public link back on, turning it off again or removing a custom public URL they just gave it. And anything a former member types in an editor they already have open, in the few seconds before it closes, is saved to the admin's note — the same as when someone is removed from a group. The sender was allowed to do each of these when they sent it; the window is only between that check and the write (#175).
- **Deleting a group can occasionally fail with "The server is busy"; trying again works.** If the group is deleted at the same moment as the links between two of its notes are being updated (which happens as someone edits them), or as another group is being given to the same admin, the database can find the two changes waiting on each other and cancel one of them. With transfer, the request also answers `409 server_busy` when the receiving admin's storage space stays busy with other additions for more than 5 seconds, or when that admin's folders stay busy with other changes for more than 5 seconds. Either way, the request can also answer `409 server_busy` when the group's own folders stay busy with other changes for more than 5 seconds. When it cancels the deletion, the request answers `409 server_busy`, nothing is deleted or moved, and the dialog stays open so you can try again (with **Delete everything**, people who had one of its notes open have already been told it was deleted — see above). Every note in the group is handled in that one deletion, so the more notes a group has, the longer it takes (#175).
- **Upgrading a pre-release build that already had groups moves every group note out of its owner's hands, and some old links stop working.** This only applies to servers that ran a build of the `main` branch from after #103 and before migration 0012 — released versions up to 0.4.1 had no groups. Notes that were in a group now belong to the group: their former owners keep only what their group role allows (an ordinary member can no longer delete the note or manage its public link; someone no longer in the group loses access entirely), notes that were read-only for their group became editable by every member, the group's admins can now delete any of its notes, manage their public links and change their URL name (through the API), per-person shares on them were removed, custom public URLs (`/p/<username>/<name>`) on them stopped working (the anonymous token link still works), and their previous-name redirects were dropped — only each note's current `/n/…` address forwards to its new `/g/…` address, for a month; a note whose URL name clashed with another note in the same group was renamed, normally with a numeric suffix. The migration can't be undone: back up the database before upgrading (see [Upgrading and rolling back](./self-hosting.md#upgrading-and-rolling-back)); after the migration that pre-release build still starts on the migrated database but can't serve notes any more — loading a note or the note list fails with a server error — because columns it reads are gone. If you're upgrading from 0.4.1 or earlier, there were no groups, so none of these changes affect your data (#175).
- **A username you rename away from is free for anyone to take — including you — and old links can then lead to the new owner's notes.** Every account has a username (`handle`, shown in **Settings → Account info** as **URL name**; every personal note's URL carries it as `/n/<username>/…`). Renaming frees the old name at once: anyone, including the original owner changing their mind, can take it — by renaming to it, by signing up, accepting an invitation or first signing in with a sign-in service when the name derived from their email (or the service's username) is the freed name. Links shared under the old name are not forwarded to your new name. Once someone else has taken the name, those links can open the new owner's notes instead (for `/n/`, only for people who can read them) or give 404. In-app links that were already converted to note ids (#269) are not affected; only addresses shared outside the app, and in-app links that weren't converted (such as `/p/<username>/…` links, or links to notes that couldn't be matched), follow the new owner. Renames are limited to 5 **successful** changes per rolling 24 hours — going back to one of your old names counts as one — counted from a record of renames kept in the database, so the quota survives server restarts. Failed attempts consume no quota.
- **Renaming your username breaks your `/n/<old-name>/…` links immediately, and other people's open tabs keep offering the dead links until their caches refresh.** Personal note URLs carry your username, and a rename gives up the old one; nothing forwards those links (see the entry above for what happens once someone else takes the name) — except the old address of a note that moved into a group, which forwards to the group address for a month after the move. Your own session updates immediately; collaborators' cached data — sidebar links, copy-link buttons — only refreshes on their next background refetch (typically on window focus). Old-form `/notes/…` links are unaffected.
- **Accounts created while running a rolled-back (pre-username) server version aren't in the username registry until the next upgrade boots.** Rolling back to an older image after the username migration leaves newly created accounts' names outside the registry. Until the upgraded server's next startup completes its idempotent backfill, picking an automatic name for a new account can't see those accounts' names, but duplicates are still prevented (`users_handle_unique`) and renaming still works without the registry row; at startup the server logs a warning for any account whose name is still outside it.
- **Behind a reverse proxy with `TRUST_PROXY` unset, one client can rate-limit the public share pages for everyone.** The public endpoints throttle unresolvable tokens per client address; with `TRUST_PROXY` unset behind a proxy, every visitor shares the proxy's address and therefore one bucket — anyone sending ~120 garbage tokens a minute makes every anonymous reader see 429s for the next minute (members and editing are unaffected). This is the same shared-bucket failure as the login-lockout case in the [self-hosting guide](./self-hosting.md#deployment-prerequisites): if you run behind a proxy and use public sharing, set `TRUST_PROXY`. The same applies to the per-IP limit on **invalid Bearer attempts** (30/min): unset behind a proxy, one misbehaving client's bad tokens make every *other* invalid or expired token behind that proxy get `429` instead of `401 invalid_token` until the window passes — so those clients can't tell they should re-authorize. Valid tokens are limited per user, not per IP, and are unaffected (#107). Invalid password reset links and invitation links collapse the same way: 30 per minute per IP together, then `429 too_many_requests` instead of `410 reset_link_invalid` or `410 invite_invalid`; valid links are not affected. The three OAuth endpoints' per-IP buckets (`/oauth/register` 30/hour, `/oauth/authorize` 30/min, `/oauth/token` 60/min) collapse the same way behind an unset `TRUST_PROXY`: everyone behind the proxy shares each bucket, so a few people authorizing apps at once can push each other into `429`.
- **API tokens survive a password change.** A Personal API token is a separate credential — changing the account password does not revoke it, the same way personal access tokens work on GitHub and similar services. If a token may have leaked, revoke it in Settings → Authorization → API tokens. See [API tokens](./api-tokens.md#security-notes) (#107).
- **The API-token quota is best-effort.** The 20-active-tokens limit is checked and then the row is inserted, without locking the user; two creations racing each other can briefly leave you at 21. The limit exists for tidiness and UI readability, not as a security boundary. OAuth app authorization shares the same cap and the same race — two apps authorizing at the same instant can also briefly exceed it (#107/#132).
- **Expired API tokens are cleaned up opportunistically, not on a schedule.** Tokens that have been expired for more than 30 days are deleted on any of the six triggers listed in [API tokens](./api-tokens.md#revoking); on an idle account they stay in the list, marked *Expired* and unusable, until then. An *App* row marked *Expired* is different: it already qualifies for removal and goes at the next trigger without a further 30 days, and the app can still reconnect until then (#107).
- **The `WWW-Authenticate` challenge points at a single resource.** Every endpoint that accepts an API token returns the same `resource_metadata` URL, the one for `/api/mcp`, regardless of which endpoint was called. RFC 9728 says a client must discard resource metadata whose `resource` doesn't match the URL it was fetched for, so a strictly conforming OAuth client that starts from, say, `GET /api/notes` will ignore the hint — only `/api/mcp` (the MCP client's actual entry point) yields metadata it will accept. This is deliberate: one protected resource keeps one authorization server and one token audience, and the alternative (only `/api/mcp` advertising metadata) would leave every other token-accepting endpoint's `401` with no discovery information at all. The OAuth authorization server's `resource` parameter (required on `/oauth/authorize` and `/oauth/token`) is checked against this same single identifier — there is one resource identifier for the whole site, not one per endpoint, and tokens are not bound to an audience per request (#107/#132).
- **OAuth for MCP clients only works for clients that run on your own machine.** The built-in authorization server accepts loopback redirect addresses only (`localhost`, `127.0.0.1`, `[::1]` — any port). It is designed for Claude Code connecting directly (needs an `https://` deployment), and for Claude Desktop through a local stdio bridge such as `mcp-remote` (see [How to connect](./mcp.md#how-to-connect)). On a plain-`http://` deployment OAuth is not available; connect with a personal API token instead (#159). The claude.ai web / Desktop "custom connector" cannot work at all: those connect from Anthropic's cloud and cannot reach a LAN instance. Because registration is unauthenticated, accepting remote redirect addresses would let anyone register a nice-looking app name for one-click phishing, so this is deliberate. The flip side is a threat model that assumes one person per machine: on a shared workstation, multi-user Linux box or terminal server, another local user could listen on loopback and collect a code someone else approved — the consent page's loopback warning is there for that reason (#132).
- **Neither the consent screen, nor creating a personal API token, nor changing a credential's scope asks for your password again.** All three act only on your existing browser session — anyone who gets hold of a session you've already unlocked (a shared computer, an unattended browser) can authorize an app, mint a token, or raise an existing token's or app's scope (on its row in **Settings → Authorization**, or with `PATCH /api/auth/tokens/:id`), acting as you across every note the scope reaches — metadata, reading and rewriting note content, with `notes:move` moving notes into groups and copying them there, and with `notes:delete` moving notes to the trash and restoring them — no re-authentication required (#132, #239, #181).
- **App names on the consent page and in Settings are self-reported and unverified.** Dynamic client registration takes whatever `client_name` the app sends (control, bidi-override and zero-width characters are rejected so the name cannot visually push the "not verified" note off the page, and it is rendered bidi-isolated). Trust the redirect address shown on the page, not the name (#132).
- **The authorization server only supports dynamic client registration, which the MCP spec has marked deprecated.** The newer alternative (Client ID Metadata Documents) requires the server to fetch an arbitrary URL supplied by the client, which is a server-side request forgery surface, so it is not offered; the metadata document simply doesn't advertise it and compliant clients fall back to registration. If clients drop the fallback in the future, this will need revisiting (#132).
- **OAuth credentials are not short-lived, and there is no revocation endpoint.** Access tokens last 24 hours and refresh tokens (like personal tokens) never expire; the MCP guidance to issue short-lived tokens is deliberately not followed. Revocation is only ever done from Settings → Authorization, by an admin disabling the account, or by the automatic clean-up of apps unused for 30 days (#132).
- **A replayed authorization code, or a replayed refresh token, is simply rejected — it does not revoke the credential it belonged to.** Codes are single-use and refresh tokens rotate on every use, so a stolen copy fails with `invalid_grant`; but the server cannot tell a replay from a typo (no `used_at` record is kept), so it does not perform the optional "revoke everything derived from this code" reaction. PKCE already makes an intercepted code unusable (#132).
- **A failed token exchange does not consume the authorization code.** If the exchange fails (wrong PKCE verifier, mismatched `client_id` or `redirect_uri`, credential limit), the whole transaction is rolled back and the code stays valid until its 10-minute expiry, so the client can correct the request and retry. During that window PKCE is the only thing standing between an intercepted code and a credential (#132).
- **Denying on the consent page — or hitting the 20-credential limit there — discards the request.** A pending request is single-use whichever way you answer, and the limit check runs after it has been consumed. In both cases you have to start again from the app (after revoking a credential, in the limit case) (#132).
- **Unknown scope values are silently ignored rather than rejected.** An app asking for `notes:read offline_access` gets `notes:read`. `notes:move` and `notes:delete` only come together with `notes:write`, so an app asking for either of them without `notes:write` doesn't get it either: `notes:read notes:move` and `notes:read notes:delete` also get `notes:read`. The granted scope is reported in the token response, which is what clients are supposed to honour (#132).
- **`resource` is required on the authorization request**, stricter than RFC 8707's "may". MCP clients always send it; a hand-written script must too, and it must be exactly `<PUBLIC_URL origin>/api/mcp` — no trailing slash, case-sensitive path. Leaving it out entirely gets `invalid_target`, not `invalid_request` (#132).
- **Confidential clients are not supported.** There is no `client_secret`, and the `client_credentials` grant type does not exist — every app is treated as a public client (#132).
- **Scopes cover your whole account.** There is no per-note or per-group scope: `notes:read`, `notes:write`, `notes:move` and `notes:delete` each apply to all the notes your account can reach. On the consent page you choose which of the scopes the app asked for it gets — reading is always included. The first time you authorize an app, **Create and edit notes**, **Move or copy notes into groups** and **Move notes to the trash and restore them** start unticked, so pressing **Allow** straight away gives it read-only access; re-authorizing an app starts from the access it has now (only what it is also asking for this time). That starting point only means the same client registration is asking again — a `client_id` isn't a secret, and any program on your computer can start an authorization with it — not that it is the same program. You still have to press **Allow**, the ticked boxes are only the page's default, and what to go by is still the redirect address and whether you yourself just started that program (see [API tokens](./api-tokens.md#authorizing-an-app-instead-oauth)) (#132, #239).
- **An invalid Bearer token still costs a database lookup once its per-IP bucket is exhausted.** Unlike `/oauth/authorize`, where the rate limit is checked before any database work, a request with an invalid token past its bucket's limit still does one indexed lookup to determine that it's invalid before answering `429` (for a token that has the `knb_` prefix — anything else is rejected before the lookup) — a single indexed lookup is cheap, and a valid token's holder shouldn't be penalized by throttling meant to catch failed attempts (#132). Invalid password reset links and invitation links past their per-IP bucket cost the same: one lookup by the link's hash before the `429` (a link whose token doesn't have the `knbp_` shape — `knbi_` for an invitation — is rejected before the lookup).
- **Matching a `redirect_uri` against a registered one ignores the port** (RFC 8252 §7.3). Harmless today because only loopback addresses are accepted at all; if redirect addresses are ever widened beyond loopback, this comparison needs to go back to an exact match (#132).
- **Re-authorizing an app replaces its previous credential — which logs out any other running copy of that app.** One credential per (user, app) is enforced structurally. A second Claude Code window that shared the old credential gets `401` and a failed refresh, and has to run the flow again (#132).
- **An app the server has forgotten has to be removed and re-added on the client.** A registration is dropped once it is more than 24 hours old and has no live credential and no authorization code on record (even an expired one buys it one more cleanup pass) — which includes a registration whose credential you revoked in Settings, and one whose authorization was never exchanged for a credential; apps unused for 30 days are dropped with their credential too. An app that comes back with its old `client_id` lands on a plain-text page explaining this rather than the consent screen — there is no automatic re-registration on the server side. The client does not notice: it retries with the same `client_id` and waits for an authorization that will never arrive — see [Troubleshooting](./api-tokens.md#troubleshooting) (#132).
- **Changing `PUBLIC_URL` invalidates OAuth apps that cached the old issuer or resource.** The issuer and the resource identifier are derived from it; `oauth_clients` registrations themselves aren't affected — a client keeps its existing registration and just re-runs discovery — but any app that cached the old issuer or resource has to register and authorize again. An app refreshing with the old `resource` gets `invalid_target`. Existing Bearer tokens keep working until they expire, because the resource identifier is not re-checked on every request (#132).
- **Serving the authorization server over plain `http://` breaks the MCP requirement that it be `https://`.** Verified against real clients: Claude Code's built-in OAuth client refuses outright, aborting the token exchange with "Refusing to send credentials to non-https token endpoint …"; `mcp-remote` refuses the same way as of 0.14.2 (`--allow-http` lets the connection through, not the token exchange) (#159). See [How to connect](./mcp.md#how-to-connect). On a plain-http deployment the consent page and the token exchange are exposed to anyone on the same network, exactly like the rest of the site (#132).
- **The automatic clean-up of OAuth leftovers has no scheduler.** Expired requests and codes, registrations older than 24 hours that have no live credential and no authorization code on record (even an expired one buys it one more cleanup pass — this includes a registration whose credential you just revoked in Settings, and one whose authorization was never exchanged for a credential), apps unused for 30 days, and personal tokens expired for more than 30 days are all removed opportunistically, on any of the six triggers listed in [API tokens](./api-tokens.md#revoking). A burst of junk registrations therefore lingers until the next legitimate OAuth activity (#132).
- **Typing that happens in the same couple of seconds as an API write is attributed to that write.** The browser saves a burst of typing once, shortly after it stops. An API write saves immediately when it merges, and that save also flushes whatever human typing was still waiting — so the whole batch is stamped with the API write's author and agent label. No content is lost; only "who edited this last" is coarse at that boundary. Reading a note never has this effect: the read path never opens a writing connection at all (#106, #137).
- **A *rejected* API write can swallow the attribution of human typing that was waiting to be saved.** A write that is refused inside the merge (a fingerprint mismatch) still closes its connection, which is also an immediate save — and that save takes the place of the one the human typing had scheduled. The refused write writes no attribution of its own (it is gated on having actually applied something), so the result is that the human batch's "last edited" is simply not recorded. **The typing itself is saved** — only the attribution is skipped, and the next edit restores it (#106, #137).
- **`replace_all` gives every block in the note a new id.** Anything holding block ids from before — a section id you cached, an in-flight edit record, another program's outline — stops resolving. Prefer section-level operations when something else may be addressing the note; after any write, use the fresh `outline` the response returns (#106, #137).
- **An API write can succeed and still not be recorded.** Content is merged into the live document first and the history row is written afterwards, deliberately — no database transaction is held across the merge. If the row fails to be written, the answer is `500` with the content already in the note and nothing in the edit history, so that change cannot be reverted through the API (a retry is refused by its fingerprint instead of duplicating the edit). **A revert fails differently**: the content is already restored, but the revert row is not written and the original row is not marked. That original row then becomes **non-revertable** and pressing revert again answers `409 stale`. The two operation families arrive there differently — for `replace_all`/`replace_section`/`insert_after`/`append` the fingerprint check does it by itself, since restoring replaced exactly the blocks it compares, while `delete_section` (no fingerprint, and an anchor that survives a restore) is caught by a separate idempotency rule: an edit whose original blocks are back at the top level counts as already restored. Without that rule a retry would insert them a second time and leave two top-level blocks sharing one id. Neither shape is fixable by retrying (#106, #137).
- **`POST /api/notes` with `content` can leave an empty note behind.** If applying the content fails, the note row just created is deleted again on a best-effort basis; if that deletion *also* fails, the answer is still `500` but an empty, untitled-or-titled note stays in your list. Delete it by hand (#106, #137).
- **A note's edit history stores a full copy of what each edit replaced, with no size cap.** Every recorded write keeps the blocks it overwrote so it can be undone, so a note that is repeatedly rewritten in full can hold up to 100 complete snapshots of itself. Nothing trims them other than the 100-row window (#106, #137).
- **`revert_of` cascades, so "the last 100 edits" is really "at most 100".** Each note keeps its 100 most recent history rows, and deleting a row deletes any revert of it. Two consequences: reverting the *oldest* remaining edit pushes the pair out together (101 rows, revert the oldest, 99 remain), and an ordinary write's trim can take out a revert row that is still well inside the window, if the row being trimmed happens to be that revert's original. A revert row's lifetime is bounded by its original's. The second path is **deliberately left untested** — constructing it means driving a note to the trim boundary *and* arranging for the trimmed row to be some revert's original, which costs far more than it guards, since the behaviour is the same mechanism as the first path and that one has two cases holding it (#106, #137).
- **The per-note write queue and the editing runtime are process-local, and there is no cap on how many notes are written at once.** The queue only guarantees that two writes to *the same* note are serialised (see the [single-instance warning](./self-hosting.md#single-instance-warning)); writing N different notes at the same time means N concurrent headless editors, each leaving roughly half a megabyte behind that garbage collection does not reclaim, plus the cost of building it. This is the first path where an **external** request drives that work — reading content also builds one, but under the tighter read budget and without writing. Today's only throttles are the per-user write and token budgets; there is no per-process concurrency gate, so the memory ceiling under many simultaneous writers is set by how many requests arrive at once. Capping it would mean a global semaphore, or moving the runtime into a worker thread (#106, #137). Since #398 reading the written Markdown, putting it into the note and reading a note of 16 KiB or more run in the parse workers, so those editors are bounded by the number of workers; notes under 16 KiB, applying a note version and the one-time link conversion still use an editor in the server's own thread, with no cap.
- **A concurrent write is only protected where the target section has content.** The fingerprint the server re-checks at merge time is a constant for a zero-block section, so `replace_section`/`insert_after` aimed at a `_top` with no blocks, any operation on a note with no blocks at all, and `append` sent without `if_match` have no such protection. Nothing is lost in those cases — content the browser inserted during the window is kept — but where the written content lands *relative* to it is decided by the collaborative document (#106, #137).
- **The wikilink index can briefly lag an API write.** Two causes: an API write recomputes the index from the copy it edited, so a link the browser added in the same window may not be in it yet; and the index update itself can fail (a lost race with a later browser update, a busy write, or a database error — the last is only logged as a warning and deliberately does **not** turn an already-applied, already-recorded write into a `500`, since an AI would retry and duplicate the edit). Both fix themselves the next time that note's link set changes or a browser reconnects to it (#106, #137).
- **`POST /api/notes` now rejects unknown body fields.** A field the server does not recognize used to be ignored; it is now `400 invalid_body`. This is a behaviour change for existing callers — and it is deliberate: with `content` accepted, a request that misspells it would otherwise create an empty note and report success (#106, #137).
- **Merely opening an empty note in the browser editor records you as having edited it.** Mounting the collaborative editor in a browser tab on a note with no blocks normalizes it to a single empty paragraph, and that normalization is written back into the shared document over the live collaboration connection, where it travels like any other edit — so a note you only *opened* there, without typing a character, comes back with its last-edited line naming your username, dated the moment you opened it. The server has its own headless mount of the same editor — the one the read path uses to render a note as Markdown — and it performs the identical normalization, but that mount never joins the collaboration session, so the normalization has nothing to write back to and never becomes an edit; the effect described here is specific to the browser's mount. This is emergent from how the editor and the collaborative document meet, not a defect in the attribution itself. ⚠ Derived from reading the code; not confirmed end to end (#106, #137).
- **An AI's presence is only visible to people who have the note open at that moment, and it fades after 2 minutes.** The cursor is attached to the live collaborative document, so a token reading or writing a note nobody is looking at creates nothing at all — and leaves nothing to look at afterwards; the lasting record is the note's edit history. Presence is refreshed by each token-authenticated content read or write and removed after 2 minutes without one. `GET /api/notes/:id/edits` deliberately does not refresh it, and a cookie-authenticated request never creates it (#106, #138).
- **The presence registry and its counters live in the server process, so a restart leaves an already-open browser unable to see the AI until the page is reloaded.** Each AI cursor carries a counter that only ever moves forward, and a browser silently discards any update whose counter is not higher than the last one it accepted. After a restart the server counts from the beginning again while the browser still remembers the higher number, so that AI simply never reappears in that tab. Reloading the note builds fresh state and fixes it. The same applies across two server instances, which is one more reason [only one is supported](./self-hosting.md#single-instance-warning) (#106, #138).
- **That counter map is never trimmed.** It keeps one small entry per distinct (note, credential) pair the process has ever seen, and nothing removes entries — not presence stopping, not the note unloading, not the registry evicting an old cursor to stay under its 500-cursor cap. Its size is bounded only by the life of the process. This is deliberate: keeping the counter is exactly what lets an AI reappear after an idle stop, and dropping it produces the failure described in the entry above (#106, #138).
- **The AI's name tag is only lit for about two seconds at a time.** The cursor itself stays put, but the name beside it appears for roughly two seconds after each thing the AI does (and after each 10-second keep-alive) and then collapses to a bare caret; hovering the cursor shows it again. This is the editor's behaviour for every collaborator, human or not — there is no special case for API writers, and the one editor option that would pin labels open is the same option that disables the mechanism lighting them up, so there is no setting that shows the name permanently (#106, #138).
- **A token reading a note whose first block has no text puts no AI cursor in the note.** The cursor has to point at text; when the first block is empty, or is an image or a divider, there is none to point at — no matter how much content follows it — so no cursor is sent, and with no cursor the editor draws nothing, not even the name. The most common shape of this is exactly "brand-new empty note, read it, then write it": the read puts no cursor in the note, and the AI's cursor only appears with the write. The AI still appears in the note header's row of people, but clicking it doesn't scroll anywhere while it has no cursor (#106, #138, #293).
- **Viewers have no cursor, so clicking a viewer in the header's row of people doesn't scroll.** Knotebook doesn't share where a viewer is in the note, even while they click into it or select text to copy. Viewers still appear in the row, with their role (#293).
- **Someone who changes their display name keeps showing the old one in the header's row of people until they reopen or reload the note.** The name travels with their collaboration connection, which is set up when the note opens; the same is true of the name on their cursor (#293).
- **Someone whose tab loses its network stays in the header's row of people on other screens for up to about half a minute, and so does someone whose tab crashes after their connection had dropped and come back.** Closing the tab normally removes them at once. The server doesn't notice a lost network right away, and after a reconnect it no longer ties them to their new connection, so in both cases they leave the row only when their presence goes stale — 30 seconds after the last update, checked every 3 seconds (#293).
- **After a revert is refused, that row keeps offering a Revert button until the dialog is reopened.** The **AI edit history** list is refetched on success only, so a `409` — the note has moved on, or the entry was already undone — leaves the stale row on screen with a button that looks usable; pressing it again just fails again. Closing and reopening the dialog shows the true state. It heals itself, but nothing in the UI says so at the time (#106, #138).
- **A fingerprint is concurrency protection, not permission protection.** `if_match` refuses a write built on a stale read — it says nothing about whether the program sending it has read anything. An `append` sent without `if_match` skips the check entirely, by design. What bounds a credential is its scope (`notes:read`, `notes:write`, `notes:move` for moving or copying into a group, and `notes:delete` for moving notes to the trash and restoring them), the sharing on each note (for a group note, your role in its group), and the fact that most writes that put content into a note are recorded in that note's **AI edit history**, where such a write can be undone — while it is still one of the note's 100 most recent recorded writes and nothing has changed those blocks since. A `create_note` with no `content` records nothing at all (#108). Some writes aren't in that history, for example a `rename` (rename it back instead), `move_note_to_group` (a single note can't be moved back out, and the shares, public link and version history it loses don't come back), `copy_note` (the copy starts with no history) and `delete_note` (`restore_note` brings the note back, while it is still in the trash and only for someone who can restore it).
- **A per-minute read limit is not a per-turn context budget.** MCP reads are capped per call — 4000 characters of section text, 100 sections, 100 notes, 200 characters of heading (as written in JSON) — and per minute, at 120 content reads per user by default. Nothing caps the total: an assistant that keeps calling can pull roughly 480 000 characters of note text a minute (or, on a note built entirely of headings, considerably more heading text than that). If that matters for your deployment, the lever is the credential's scope, what is shared with the account and which groups it belongs to, not the rate limit (#108).
- **Notes other people shared with you, and your groups' notes, end up in your assistant's context.** `list_notes` and `search_notes` return everything your account can read, which includes notes shared with you and the notes of your groups; `search_notes` also hands over excerpts of their body text, and `read_note_section` will hand their text to whatever model is driving the client. A credential's reach is your reach — there is no per-note or per-owner scope. Each result carries `owner` and `role` so a well-behaved client can tell whose content it is looking at, but nothing enforces that it does (#108).
- **There is no cross-note view of what an assistant changed.** The **AI edit history** is per note: to review a run of edits across several notes you have to open each one. The closest thing to an overview is the `lastEdited` that comes back with every `list_notes` result: it names the account that touched each note last and, when the write came from a credential rather than a browser session, the agent name it came under (#108).
- **A wrong `PUBLIC_URL` makes MCP requests that carry an `Origin` header answer `403`.** The endpoint's DNS-rebinding guard compares both the request's `Origin` and its `Host` against the host in `PUBLIC_URL`, never against each other — comparing two values that both come from the request would guard nothing. Behind a reverse proxy the `Host` side depends on `TRUST_PROXY` being set correctly too. Command-line MCP clients normally send no `Origin` and are unaffected, so this shows up as "it works from the terminal but not from that one client", answered with a `403` whose body does not mention `PUBLIC_URL` (#108).
- **A client's cached tool list does not shrink on its own.** The set of tools is decided per request from the credential's scope, but `tools.listChanged` is `false` — the server is stateless and has no channel to push a change — so a client that connected with a `notes:write` credential keeps showing the writing tools after you revoke it and switch to a read-only one. Calling one then gets an explanatory "tool not found" result rather than a permission error. Restarting the client refreshes the list (#108). The same goes for changing a credential's scope in place in **Settings → Authorization** (or with `PATCH /api/auth/tokens/:id`), although the change itself applies from the next request: after raising it, the tools it gained only show up once the client is restarted; after lowering it, a tool it lost answers "tool not found" in the same way — except `copy_note`, which a `notes:write` credential keeps, and which then refuses `group_id` with `insufficient_scope`. Tool descriptions stay as the client first fetched them, too: after you add `notes:move`, `copy_note`'s still says that copying into a group needs `notes:move` (#239).
- **`list_notes` pages through live data, not a snapshot.** The cursor is a position in "most recently updated first" order, and only two things move a note in that order: creating one, and renaming it — changing its title or its URL name. Both stamp the note with the current time, which puts it above the cursor you are holding, where no later page reaches it. **Editing a note's content does not move it** — a content write lands in the collaborative document and in the note's own last-edited fields. So the run that silently misses notes is not "list a page, edit those notes, ask for the next page"; it is a run during which somebody creates or renames notes — a person in the browser, or an assistant: `edit_note`'s `rename` moves the note it renames to the top, and a note `copy_note` creates appears at the top. A note that gets shared with you mid-run — directly, because you were added to its group, or because its owner moved it into one of your groups — can be missed the same way, since it joins the listing at its own unchanged position rather than at the top (moving a note into a group, or deleting a group and giving its notes to an admin, can give a note a new URL name, but does not move it in this order). A note restored from the trash mid-run — in the web app, or with `restore_note` — can be missed too: restoring doesn't change its place in this order, so it comes back at its old position rather than at the top. A copy made with **Copy to… → My notes** or **Make a copy**, or copied into a group, is a new note, so it appears at the top. To enumerate everything, page all the way through before you do anything else, and page through again afterwards if other people are working in the same notebook (#108).
- **`read_note_outline` pages through live positions, not a snapshot.** A section's offset is its position in the document, and any edit that adds or removes a section shifts everything after it — so paging an outline while editing the same note can repeat or skip sections. Same remedy: list every page first, then edit (#108).
- **`read_note_section` pages through live text, not a snapshot.** Each call re-reads the note, so a section rewritten between two pages hands back the second half of the *new* text after the first half of the old — the assistant has now read a mixture that never existed as such. The `fingerprint` that arrives with the final page is computed from the section **as the note holds it at that moment** — not from the spliced text the assistant assembled — so a following `edit_note` built on it usually **matches and succeeds**, silently writing that spliced text into the note. `fingerprint_mismatch` only follows if the section is changed *again* after that last page arrived — it catches the next edit, not the one that already happened mid-read (#108).
- **Over the API and MCP, text that looks like HTML is not escaped when read.** Characters that Markdown reads as formatting, and Markdown block markers at the start of a paragraph, are escaped when a note is read (see [AI editing](./ai-editing.md#markdown-round-trip)), but `<` is not: a note whose text literally contains `<tag>` reads back with it as it is, so writing it back unchanged turns it into markup or drops an unknown tag — whether or not the text is colored — and a line break followed by such a tag, for example `<div>`, splits the paragraph there (#379). Spaces at the start or end of a paragraph or a heading don't survive writing what you read back, and in a table cell neither does a space right after a line break. Code formatting can't carry a color, so a colored stretch that comes back as code loses its color (#222). Text that is itself column or size markup reads back the same way, and writing it back unchanged is refused: a paragraph whose text is a column tag line such as `<div data-kb-columns style="display:flex;gap:16px">`, a `<div>` with `display:flex` or `display:grid` in its `style`, or an element with a `data-node-type` attribute is rejected with `unsupported_columns`, and one that contains an `<img … width="…">` with `unsupported_size` (text that is exactly the `<figure>` form of an image turns into that image instead); text that contains an element with an event-handler attribute, such as `<img src=x onerror="…">`, is rejected with `unsupported_block`. The in-app AI gets the same Markdown, so a result that keeps such text as it is gets refused the same way and isn't applied. Over the API and MCP, text that contains a `<script>`, `<style>`, `<iframe>`, `<object>`, `<noscript>`, `<title>` or `<template>` tag is rejected with `unsupported_block` too (the in-app AI drops the tag instead); a file sent back with such a paragraph unchanged still goes through, but one that changes that paragraph is refused.
- **A list or table with block or cell colors reads back as HTML as a whole.** Over the API and MCP, text and background colors can be written as one of the editor's ten color names or as a hex color, among other forms; [AI editing](./ai-editing.md#colors) has what is accepted and what is rejected with `unsupported_color`. A custom color is shown as written, in light and dark mode alike: the editor does not adjust it for contrast. On reading, colored text stays in the Markdown as a `<span>`, but a list with one item that has its own color, or a table with one colored cell, comes back as one block of HTML rather than Markdown; a file attachment's background color and the color of a block with no text are not carried; and a color that isn't one of the ten names or a lowercase `#rrggbb` color — one stored before colors were checked, or left by pasting from elsewhere — is not shown in the editor and reads back as no color, so writing the block back removes it. Text that merely looks like color markup is not escaped when read: a note that quotes `<span style="color:#ff0000">` as plain text reads back with that markup as is, and writing it back unchanged turns the text into real color (as a color name in the quoted markup does). See [AI editing](./ai-editing.md#colors) (#222, #378).
- **A note with thousands of distinct custom colors loads more slowly.** As colors appear in the editor, it adds style rules for each distinct custom color used on a block or table cell, and for each one used in text. In one measurement, 2000 distinct block colors added about 1.7 seconds to the first load; colors inside text, and typing, were not affected. Notes with a handful of colors are not noticeably affected.
- **Some image widths don't travel over the API and MCP, and sizes not written in the form reading gives are refused.** An image whose preview is turned off reads back as a link, without its width, and writing that back gives a paragraph with the link rather than the image. An `<img>` with a `width` inside a table cell is refused with `unsupported_size`; this version has no images in table cells. From this version, a `width` on an `<img>` or a `colwidth` not written as [AI editing](./ai-editing.md#sizes) describes, and a `<div>` laid out with `display:flex` or `display:grid` other than a row or column of [side-by-side columns](./ai-editing.md#columns), reject the whole write (`unsupported_size`, `unsupported_columns`) — including text of a colored table with column widths or a colored image with a set width as an earlier version read it. And an image inside a toggle heading or a toggle list item whose address is relative but isn't one of this site's uploads (such as `/static/pic.png`) is saved with the site's full address once that block is written back. A `<figure>` image written inside a quote (`> <figure>…</figure>`) is dropped without an error, leaving an empty quote, because a quote holds only text.
- **Over MCP or the API, a link to a note with no text of its own can still get the note's old title as its own text when it is written back.** Writing back the text that reading showed for such a link, or that you wrote for it yourself, keeps it following the note's title (see [MCP](./mcp.md#links-to-notes)), but the server only remembers each such text for up to 24 hours after the last read or write that had it — less if the account reads or writes more than 10 000 of them in that time, or the site more than 100 000 for everyone together, since only the latest are kept — and forgets everything when it restarts. It remembers only what `read_note_section` (the whole section, also the pages of it you didn't fetch), `GET /api/notes/:id/content` and the `current` in a `409` from `POST /api/notes/:id/edits` or its revert showed to the same account, and the links that account wrote with `edit_note`, `create_note`, `POST /api/notes/:id/edits` or `POST /api/notes` — not what a bulk-transfer download showed, nor links in files sent back. So if the linked note was renamed after you read it and the server no longer remembers that read, writing the link back with `edit_note`, `create_note`, `POST /api/notes/:id/edits` or `POST /api/notes` saves the old title as the link's own text, and the link stops following the note's title; read the section again before writing it. The other way round, text you set on purpose to a title the note had when a read showed it to you, or when you wrote such a link, within those 24 hours is saved as no text of its own, and the link shows the note's current title (#387).
- **`edit_note`'s reply carries fingerprints only for the page its change landed on.** A successful write returns at most 100 sections' worth of per-section fingerprints, starting at the section that now holds what was written (for `replace_section` and `insert_after`) or at the start of the note (for `replace_all`, `append` and `delete_section`). On a note with more than 100 sections, a run of edits that moves outside that page has to go back to `read_note_outline` and `read_note_section` for the next fingerprint (#108).
- **`list_trash` and `list_folders` page through live data, not a snapshot.** Each page is read when it is asked for. `list_trash` lists the newest first, so a note moved to the trash while an assistant pages lands above the cursor it holds, where no later page shows it. `list_folders` lists folders grouped by space and then by id, not by when they were made, so a folder created while an assistant pages may or may not show up on a later page. To be sure of having everything, page all the way through again.
- **The date a note in the trash is due to be deleted can change.** `purgeAt` — in `list_trash`, in the replies of `delete_note` and `delete_folder`, and in `GET /api/trash` — is worked out from the site's trash setting as it is when you ask, not fixed when the note was moved there; the clean-up uses the setting as it is when it runs. A site admin who shortens the retention period, or switches from not deleting automatically to a number of days, moves every note's date, and notes that are then past it start being deleted at the next clean-up pass. A `purgeAt` an assistant read earlier can therefore be later than the real one; `list_trash` shows the current date for the notes you can restore (#181).
- **`delete_note` and `restore_note` can use up their budgets without changing anything.** With a token, each call spends one token write before the note is even looked up — MCP write tools spend it first, right after checking the credential's scope, except `copy_note` given a `folder_id`, which checks that folder before that and spends nothing when it refuses it — and once the access check has passed (for `delete_note`, also once the site's trash setting has been read) it also spends one write from the 30-writes-per-minute budget. A call that then fails without moving or restoring the note — for example because the check made again inside the transaction no longer passes (`not_found` or `forbidden`), or because the transaction gave way to a concurrent one (`server_busy`) — has still used both. And a call whose note was moved or restored, but which can't read it back afterwards — for example because someone changed it again in the meantime — answers `not_found` although it did act (#181).
- **Body-text search can lag the editors by a few seconds.** The sidebar's search, `GET /api/search` and `search_notes` find a note's body text as the collaboration server last saved it — during active editing that's roughly the 2-second save debounce, up to about 10 seconds under continuous typing, and at once when the last person with the note open disconnects. A write made through the REST API or an MCP tool, including a file sent back with `write.curl`, is indexed before its reply comes back. If indexing a save fails (the `app` log shows a warning starting `全文索引更新失敗`), that note's body text stays as it was last indexed until the note is saved again after another edit, or until the server next starts (#93).
- **Attachments, text in images, media captions and file names, and link addresses aren't searched.** Body-text search covers the text of a note's blocks — code blocks, the source of mermaid diagrams and links to notes (by their own text, or the text they were made with — see below) included. It doesn't look inside uploaded files or images, and it skips what a block keeps as a setting rather than as text: an image, video, audio or file block's caption, file name and URL, and the address a link points to (the link's own text is searched) (#93).
- **Only about the first million characters and the first 2000 sections of a note are searchable.** Body-text search indexes at most 1,048,576 UTF-16 code units of a note's text — most emoji count as two, and the line break between two blocks of the same section counts as one — and at most 2000 sections that have text. What gets left out is the **end** of the note: everything after the point where either limit is reached. A log-style note that keeps growing through `append` therefore loses its **newest** text from search first. A note that was cut has `capped = true` in its `note_search_state` row (so does a note from the block-id entry below) (#93).
- **The sidebar searches body text only from the second character, 20 notes at a time.** With one character typed (after trimming spaces), the sidebar matches titles only and says so; with more than 200 (counted in UTF-16 code units, as `GET /api/search` counts them, so most emoji count as two) it also matches titles only. It shows title matches and body-text matches together in one list with one entry per section that contains the text: each entry has three lines (the note's title, its location, and the excerpt around the match), where the location is the same label the rest of the sidebar uses, such as My notes, a group's name or Shared with me, followed by the folders. The server reports only the first match in each section, so several hits inside one section are one entry, which jumps to the first of them, and a note shows at most three sections: a note whose text appears in more sections shows only the first three, one after another in the order the server sent them, each repeating the title and location. A note that matches only by title is one entry of two lines (title and location) and opens the note without jumping anywhere; a note that matches both ways is listed only by its body-text entries. Notes whose title matches come first (in the sidebar's order), then the rest in the server's order. Once the body-text results have arrived (and aren't out of date because you kept typing), the count above the list is the number of entries, not of notes (“N results”); until then it is the number of notes whose title matches (“N title matches”). Body-text matches come 20 notes at a time — **Load more** gets the next 20, and a note whose title matches gets its excerpts only once the page that holds its body-text match has loaded — and only notes that are also in the sidebar's own note list, so until that list refreshes, a note created elsewhere a moment ago may be missing, and one renamed or moved elsewhere is shown as it was. Every body search, including **Load more**, counts against the same 60-searches-per-minute budget as `search_notes`; when it runs out, the sidebar says so and title matching goes on working (#93).
- **Clicking a body-text match jumps to the first block in its section that contains the search text; if none is found it stops at the section heading, also scrolled to the middle (or, for a match before the first heading, at the top of the note), and a reload jumps there again.** A section runs from one heading to the next heading of the same or a higher level; deeper headings don't start a section of their own, and a heading inside a side-by-side column doesn't either. The note opens and goes straight to the first block of that section (nested list items and column contents included — the innermost block that actually holds the text; a table cell, code, or diagram source counts as its block's text) whose own text contains the search text, scrolling it to the middle of the note's scrolling area and marking it (it flashes twice (clearly, under 2 seconds in total, once the scrolling has settled — also when your system asks for reduced motion, since it is a locating cue that flashes only twice) and then stays highlighted, in the same colour and strength, until you press Esc, close the search by clearing the box (or, when the box is already empty, switching sidebar tab), click another search result, or leave the note; closing the navigation drawer on a narrow screen doesn't count as closing the search, and an Esc that a dialog, menu or selection mode already used doesn't clear it; the mark follows the block when the note is edited or someone else changes it, and goes away if the block is deleted; links to a section or between notes still just flash for two seconds) (a block near the start or the end of the note, without enough content above or below it, can't be brought to the middle and stays where the note stops scrolling; for a block taller than the scrolling area, such as a list item with many sub-items or a long code block, the scroll centres that block's own first line, and when that line itself is taller than the area it goes to the top; ordinary `#` section links, links between notes, and copied links still put their target at the top); the matching words themselves aren't highlighted in the note. If that block sits inside a collapsed toggle, the flash is on the outermost collapsed toggle, since its contents are hidden. The comparison is the one used to mark words in the excerpt: letters are compared lower-cased only (no full-width/half-width folding, so `a` and `a` are different, as they are for the search itself). When nothing in the section matches that way, the page stops at the section heading instead. That can happen when a link to a note whose target was renamed and has no label shows the new title where the index has the old one, for a text such as `İ` whose lower-casing changes its length, or when the search index is behind the note's current text. The search text travels in the browser's navigation state, not in the address, so a copied link only scrolls to the section; the browser keeps that state in the history entry across a reload, so reloading the page jumps to the block again (the search box is empty after a reload, so clear that mark with Esc), the same way it scrolls again to a `#` section link (#93, #367).
- **Right after an upgrade, older notes can be found by title only until the server has indexed them.** The first start of a version with body-text search indexes the existing notes in the background, after the server is already answering requests; the `app` log shows `全文索引回填開始` when that starts and `全文索引回填完成` when it is done. Until it gets to a note, the sidebar's search, `GET /api/search` and `search_notes` find that note by its title only. Stopping the server partway through is safe: the next start carries on with the notes not yet done (#93).
- **How far search ignores case depends on the database's locale.** Matching lower-cases both your text and the note's using the database's own rules. The database image in Knotebook's compose file is initialized with the `en_US.utf8` locale, under which letters outside ASCII match regardless of case too (`Ä` finds `ä`, `Σ` finds `σ`); in a database created with the `C` locale, only ASCII letters do. Title search works the same way (#93).
- **Every search reads through the body text the searcher can see.** There is no text index behind body search: a search for text that appears nowhere looks through every section of every note the caller can see, so the cost of a search grows in step with the amount of text visible to that account. When searches get slow — a 95th percentile above 500 ms — or the body text visible to one account grows past about 200 MB, it is time for a different approach (#93).
- **A link to a note with no text of its own is searchable under the text it was made with, not under the note's current title.** The index stores a link to a note as its own text when it has some, and otherwise as the text the link was made with — the title its target had then, for a link made with `[[` or one whose text was that title — so after the target note is renamed, searching for the new name doesn't find such a link, and searching for the old name still does. The first start of 0.11 indexes every note again in the background (the `app` log lines are the ones described above); until it gets to a note, a note that hasn't changed since the upgrade is searched as the previous version indexed it (#93, #269).
- **Sections with a duplicated or malformed block id aren't searchable, and neither is any body text of a note with a top-level block whose id isn't a non-empty string.** A section's text is indexed only when the id of the block that starts it is a valid section id (1–64 letters, digits, `_` or `-`) that no earlier section of the note uses; a section that fails either test can't be found by its body text. If any top-level block's id is not a string, or is empty, none of the note's body text is indexed — its title still is. `read_note_outline` and `read_note_section` can't read such a note either (#93).
- **Typing a markdown link where the URL contains an unescaped `)` closes the link at that first `)`, leaving the rest as plain text.** Typing `[a](https://x.com/foo)bar)` produces a link to `https://x.com/foo` followed by the plain text `bar)` — the same place a link written this way ends under Markdown's own rules, so it is not specific to typing it live (#99).
- **A typed markdown link only converts when the whole markdown syntax reconstructs back to exactly what you typed — a title, angle brackets around the URL, a trailing space inside the parentheses, or styled display text all block that.** Pasting the same text still produces a link in every case below, but typing it does not, because the conversion insists on reconstructing the identical text before it will act: `[a](https://x.com "title")` (the title is dropped either way, but typing still declines), `[a](<https://x.com>)`, `[a](https://x.com )` (a trailing space before the closing parenthesis), and `[*a*](https://x.com)` or `[**a**](url)` (italic or bold display text). This list is not exhaustive — anything the parser accepts but doesn't hand back verbatim behaves the same way. One case that looks similar but isn't: `[a]( https://x.com )` (a space *before* the URL too) does produce a link, just not through this conversion — Knotebook's editor has its own built-in autolinking that turns a bare URL into a link as soon as a space follows it, so typing that leaves you with `[a](` and ` )` as plain text bracketing a separate link whose text is only the URL, rather than either a clean link or plain text (#99).
- **Typing a markdown link does nothing while a suggestion menu is open** — `[[…]]`, the `/` menu, or any other — and closing the menu doesn't retroactively convert what you already typed; type the `)` again (or just the link text again) after closing it (#99).
- **Backspace right after a link forms usually reverts it to plain text — but a collaborator editing the note anywhere in the meantime cancels that, so Backspace just deletes a character like normal.** In a note nobody else is editing at that moment, pressing Backspace immediately after the link forms reverts it to plain text; whether a following Ctrl+Z then brings the link back, or clears the text you typed since your last pause, depends on whether you paused before that Backspace — pressing it in the ordinary rhythm of typing, with no pause, and then pressing Ctrl+Z clears the run of typing that led up to it (the Backspace and that typing are merged into one undo step), while anything typed earlier, before a pause, is unaffected; pausing at least half a second before pressing Backspace keeps it as its own separate step, so a Ctrl+Z at that point undoes only the Backspace and brings the link back, not plain text. In a live collaborative note, any change arriving from someone else — anywhere in the note, not just near the link — clears the "revert on Backspace" window the instant it arrives; from then on, Backspace after the link just deletes the last character instead. The same applies when the link you typed points to a note in your note list and becomes a link to that note at once (for other addresses, see the entry on links looked up on the server below): Backspace right after it forms gives back the typed text, and if you paused before that Backspace, a Ctrl+Z after it brings the link to the note back (#269). This is existing editor behavior, not specific to markdown links (#99).
- **A markdown link won't convert if a wikilink sits inside the `[…](…)` syntax itself — one elsewhere in the same paragraph doesn't stop it.** A wikilink just before or after the link syntax you're typing has no effect on it, but one anywhere inside it — in the display text or in the URL — leaves the whole thing as typed instead of converting. Nothing is corrupted (#99).
- **Typing `` character by character never turns it into an image.** This isn't new — typed text never became an image before this change either. Pasting the same text still produces a real image, and so does an AI assistant or API client writing that markdown into a note directly — both go through the same Markdown parser, just not by typing (#99). The address in markdown written by an API or AI client is not checked: it isn't verified to exist or to belong to the note it's written into, and when it points at another note's upload, a signed-in reader sees the image only if they can read that note, and a public page never shows it (#200).
- **A markdown link doesn't convert while you're typing inside bold or italic text.** The link syntax itself carries the surrounding bold/italic formatting, and the conversion deliberately declines to touch text that carries any formatting, so it leaves it as typed — even if you turn the formatting back off before typing the closing `)`, since it's the whole span the syntax would replace that has to be plain, not just its last character. Typing the link syntax entirely outside the bold/italic run converts normally (#99).
- **A markdown link near the end of a very long paragraph can fail to convert once the link's own syntax is close to or past about 500 characters.** Only roughly the last 500 characters of a paragraph are scanned for a link to convert (a limit of Knotebook's own, not the underlying editor); once a paragraph is long enough to trigger that, a link whose own `[display text](url)` exceeds that length gets cut by the boundary and doesn't convert, regardless of how much text comes before or after it. A shorter link syntax elsewhere in a long paragraph is unaffected — this only bites when the link's own display text or URL is unusually long (#99).
- **Typing the closing `()` first and filling in the display text or URL afterwards never turns it into a link.** The conversion is decided on the keystroke that types the closing `)` — so `[abc](https://x.com)` converts however you assemble it, as long as that `)` is the last character you type: typing the brackets first, filling in the display text, then typing `(https://x.com)` at the end still converts. But once a `)` is already sitting in the document — for example after typing the skeleton `[]()` or `[abc]()` and then going back to fill in the brackets or the parentheses — nothing you type afterwards converts it, because you never type that `)` again. Delete it and type it again (or use the "Link" item in the `/` menu) to convert (#99).
- **Renaming a note twice in very quick succession can make the editor reconnect once.** When a note is renamed, its address changes to the new one without reloading the note. If the second rename's response arrives before the page has finished switching to the first new address, the page can briefly treat the address as a different note and reconnect the editor once (#179).
- **Right after going back into the settings window with the browser's Back button, following a link to the current note's old address stays on the current note, even if another note now has that address.** Old addresses are not reserved: after a note is renamed from `foo` to `bar`, a new note can get `foo`. Normally a link to `/n/<you>/foo` opens that other note. But if you opened and closed the settings window, then renamed the note, then pressed Back to return to the settings window, the first navigation to that old address from there keeps showing the current note and moves the address bar to its current name (#179).
- **Following a link to the current note's own old address briefly reconnects the editor.** If the note was renamed and its old address hasn't been taken by another note, opening that old address (from a link, not with Back or Forward) looks the note up again: the editor shows the loading state for a moment, reconnects, and the address switches to the note's current name (#179).
- **Turning a link into a link to a note drops its formatting.** When a link to a note on this site becomes a link to that note — when you paste, drag in or type it, when an API or AI client writes it, or during the one-time conversion after upgrading — bold, italic, code, color and other formatting on its text are dropped; the text itself is kept (#269).
- **Two ordinary links with the same address that touch become one link to a note.** The editor joins two ordinary links with exactly the same address and nothing between them into one link (two links to notes, or two addresses of the same note written differently, are not joined) — for example when you paste or drop a link right at the end of a link with the same address — so they become one link to that note whose text is both texts joined. Its text can be changed in the link's toolbar, which opens when you hover over the link or, on a touch screen, press and hold it (#269).
- **Tabs that were open before the upgrade to 0.11 don't show the reload notice — they keep trying to connect.** From 0.11 on, when an upgrade changes how the editor stores notes, a tab opened before it is refused and shows a notice asking you to reload. Tabs opened before the upgrade to 0.11 don't have that notice yet: they keep retrying in the background, showing "Connecting…" and then an offline warning, and edits made in them that hadn't synced are lost when you reload. Reload every open tab after upgrading (#269).
- **A link to a note the writer can't see stays an ordinary link — even for readers who can see that note.** Whether a link becomes a link to a note is decided with the permissions of whoever writes it (for the one-time conversion after upgrading, see the entries below). If the writer can't see the target, the link stays an ordinary link, even when someone reading the note later can (#269).
- **Some links to this site never become links to notes.** A note address with a query (such as `?present`) or with a `#…` part other than `#b-<block id>`, and a public page address (`/p/…`) stay ordinary links. An ordinary link you move or copy by dragging it within the same note in the same tab stays as it is; dragging it into the note open in another tab makes it a link to the note (#269, #352).
- **Dragging a note from the sidebar's Recent tab into a note adds the note's title and its location as two paragraphs of their own, each a link to the note.** Each row of the Recent tab holds the note's title and its location (for example "My notes") on two lines, and dragging it carries both. The paragraph with the location has the location as the link's text. This was measured in Chromium; other browsers were not checked. The same drag already added both paragraphs before dragged links were converted, as ordinary links (#352).
- **A pasted, dragged-in or typed link that the page has to look up on the server becomes a link to a note a moment later.** A link whose address matches a note in your note list by its current address is converted together with the paste, drop or typing. Other addresses on this site — for example an older address of a renamed note, or any address while your note list hasn't loaded yet — are looked up on the server first (up to 20 different addresses for each paste, drop or edit; any others stay ordinary links), and become a link to the note if it is one you can see. If you paused before it changed, Ctrl+Z turns it back into an ordinary link, which then stays an ordinary link. If it changed right after you made it, Ctrl+Z can remove the link together with its conversion (#269).
- **An ordinary link whose text is a single character can't open its toolbar.** The editor finds the link under the text cursor or the mouse by looking at a position inside the link's text, and a one-character link has none — so neither a mouse hover nor, on a touch screen, a long press opens its toolbar (this comes from the editor component itself). Links to notes aren't affected.
- **A stylus that can't hover has no way to open a link to a note's toolbar.** The long press that opens it on a touch screen only applies to fingers; with a stylus (for example most Apple Pencils on an iPad), tapping a link to a note opens it, and the toolbar for changing its text or target or removing it doesn't appear. Use a finger, or a mouse or trackpad.
- **The toolbar of a link to a note can't be opened from the keyboard.** The arrow keys move the text cursor past a link to a note rather than selecting it, so changing its text or its target, or removing the link, needs its toolbar, which opens only with a mouse hover or, on a touch screen, a long press.
- **The "#" button that copies a link to a heading needs a mouse pointer or the text cursor.** It appears when the mouse pointer is over a heading, or when the text cursor or a selection is in it. Editors can also copy a link to any block, headings included, with **Copy link to block** in the block's drag-handle menu (#269).
- **On a public page, links to notes that aren't published under a custom public URL show as plain text — but the page's data still contains their target's id and the text they were made with.** A link to another note opens it from a public page only when that note has a public link with **Anonymous link** turned off. Links to notes that are private, members-only, or public only through an anonymous link are shown as their own text, or the text they were made with, except a link to a place in the same page, which scrolls there. The content the public page downloads still contains each link's target note id and that text (#269).
- **Writing back a link to a note you can't see, that was deleted, or that is in the trash, turns it into an ordinary link.** Over MCP or the API, such a link reads as `[text](/notes/<id>)` with its own text or the text it was made with; writing that Markdown back saves an ordinary link, because a link is only saved as a link to a note when the writer can see that note. The in-app AI works the same way, without a warning: a link to a note in its result becomes an ordinary link when that note isn't in your note list — you can't see it, it was deleted or is in the trash, or the list hasn't loaded yet (#269). Restoring a note from the trash doesn't turn such links back into links to it.
- **In a group note, the one-time conversion after upgrading only converts links to notes in the same group.** Links in a group note that point anywhere else — another group's notes, personal notes — stay ordinary links; to convert one, delete it and paste the address again, if you can see the note (#269).
- **A link to a block that was later deleted still opens the note, at the top.** The link isn't marked as broken (#269).
- **Over MCP and the API, only a note's current address becomes a link to the note.** A renamed note's previous address, a previous custom URL, and a `/notes/<slug>` link from before 0.3.3 stay ordinary links, even when they still open the note in a browser (#269).
- **Links the in-app AI writes are only matched against your note list.** A link in the AI's result whose address needs the server to resolve it — an older address of a renamed note, for example — stays an ordinary link (#269).
- **Right after upgrading, an ordinary link written into an older note before the conversion reaches that note is converted with the owner's permissions.** The conversion processes the notes that existed at the upgrade one at a time, so on a site with many notes it can take a while. A link to a note on this site written into such a note in the meantime — for example by someone who can't see the target — is converted by it as if the note's owner had written it (in a group note: only links to notes in the same group). The link keeps its own text, if it has one, by the same rules as everywhere else, and each reader still sees the target's current title only if they can see that note (#269).
- **On a public page, a link to a block of another note can scroll to a block of the page itself.** A link that points to a place in a note is followed on the public page whenever the page has a block with that id. A note copied from another keeps its blocks' ids, so a link pointing to a block of the original note scrolls to the matching block of the copy (#269).
- **Notes created after the upgrade are not converted.** The one-time conversion only processes the notes that existed when the server was upgraded. Ordinary links in notes created later — content imported after the upgrade, or a copy made of a note before the conversion reached it — stay as they are; new links you paste, drag in, type or write are converted as you make them (#269).
- **A presentation opened from a public link is not live.** It picks up changes only when the page fetches the note again — for example when the tab regains focus — and then stays on the same slide when it can.
- **The cover slide's title can lag behind a rename by someone else.** The title comes from the note's details, not from the live document, so another person's rename reaches the cover when your copy of the note is refreshed (for example when the tab regains focus).
- **A link to a note in a presentation can show an old title.** In the app, it opens the linked note in a new tab (one to a note that was deleted, or that you can't see, can't be clicked); one with no text of its own keeps the linked note's old title until that slide's content changes — renaming the linked note does not redraw the slides by itself. On a public link (even if you are signed in) it is plain text that can't be clicked, showing the link's own text, or the text the link was made with (#269).
- **The presentation overview (O) shows the content of only the slides near the current one.** Up to 10 slides either side (6 on a phone) are drawn; farther ones are empty frames until you move closer. This is reveal.js's view distance.
- **Opening a presentation link while signed out starts at the cover after you sign in.** The slide part of the address (`#/…`) is not carried through sign-in.
- **Changing the slide part of the address by hand while presenting does not move to that slide.** It is read when the presentation opens; reload to jump there.
- **On touch screens, a swipe doesn't change slides on a slide that scrolls, on a table, on a code block too wide to fit, or on a video or audio player.** There a swipe is left to the content, which scrolls if it can; use the arrows on the screen to move between slides.
- **iPhone Safari can't show a presentation full screen.** It fills the browser window instead.
- **Code in a presentation isn't syntax-highlighted.**
- **Diagrams in a presentation follow the same rules as in the editor**, including that a diagram can load images from other sites (see [Diagrams](./diagrams.md)).
- **Opening a presentation removes any other query parameters from the address.** The address is rewritten to end in exactly `?present` (keeping whatever follows the `#`) before the slides start, so that nothing else in the address can change how the presentation behaves.
- **A link to a `#…` anchor inside a slide isn't clickable.** The presentation would otherwise take it as a jump to another slide.
## Side-by-side columns
See [Side-by-side columns](./columns.md) for how columns work. In the examples, `[aa | bb | cc]` is a row of three columns holding *aa*, *bb* and *cc*.
- **Columns line up at the top, so a shorter column leaves blank space below it.** A row is as tall as its tallest column; nothing stretches the shorter columns to match.
- **Blocks in a column have no `+` button.** Hovering a block in a column, including a block nested under one, shows only ⋮⋮: the space between columns is too narrow for both buttons. To add a block in a column, press Enter at the end of a block there (or with an image selected, to add a paragraph below it in the same column), or type `/` in a block that has text and pick a block type: the new block goes below it.
- **A heading inside a column doesn't start a section.** Over MCP it isn't a section of its own and isn't listed by `read_note_outline` — the row belongs to the section before it. In a presentation it doesn't start a new slide, and in the editor it has no "#" button for copying a link to it.
- **Removing or replacing what is in a column never removes the column.** When you delete an image in a column (with ⋮⋮ **Delete** or the image toolbar), have the in-app AI rewrite blocks inside one column, or paste a diagram into a column, the row keeps all of its columns: a column left with nothing keeps an empty paragraph, and an empty column elsewhere in the row stays too. When a selection reaches across columns, or into or out of a row (including selecting the whole note when it has a row), the in-app AI works on whole top-level blocks instead: each selected block counts as the top-level block it belongs to, so the action gets the entire row — and the whole of any block whose indented child is in the selection — and its result replaces them; the row stays only if the result has it. Moving a column's only block out with Ctrl+Shift+↑ or ↓, or by dragging it with ⋮⋮, also leaves an empty paragraph behind — except that dragging the only block out of a column in a row of three or more columns removes that column. To remove an empty column, press Backspace at the start of it, or use **Ungroup columns** to turn the whole row back into ordinary blocks.
- **A selection that only touches a row at its edge also makes the in-app AI work on the whole row.** A selection that ends at the very start of the row's first column — dragging from a paragraph above the row to the start of the next line, for example — or starts at the very end of its last column includes the row, though none of the row's text is selected, so the action rewrites the whole row. With a "direct" action, when nothing in the row is an image, video, audio or file, that happens without a preview; **Revert** or Ctrl+Z undoes it.
- **When a selection that starts inside a row taller than the window opens the AI box, the box can be out of view.** While you type an instruction, while the response streams in and when it fails, the box sits just above or below the blocks the action works on — here the whole row — so when neither the top nor the bottom of the row is on screen, the box isn't either. The keyboard focus is in the box, and Esc closes it; scroll up to the top of the row to see it. A finished result shows at the top of the note's scrolling area as usual. The same happens when the first selected block is itself taller than the window.
- **Deleting a selection that spans columns leaves the columns in place.** With ⋮⋮ **Delete** on any block inside the selection, only the selected blocks are deleted (a block's nested blocks go with it; opening ⋮⋮ on one of those nested blocks deletes only that nested block, unless the selection covers the whole row); a column the selection empties keeps an empty paragraph, and the row keeps its number of columns. Use **Ungroup columns** to remove the empty columns. A selection that starts above the row and ends below it deletes the row. Opening ⋮⋮ on a block outside the selection deletes only that block.
- **Backspace and Delete remove a column only when it is empty, with two exceptions.** Backspace at the start of an empty column, or Delete at the end of the column before it, removes that column; when one column is left, the row turns back into ordinary blocks. The exceptions follow the editor's own rules: deleting a table in a column with the keyboard (select all of it, then Backspace) removes every empty column in the row, including that column when the table was its only block; and Delete in an empty code block removes the code block, and its column too when the code block was the only block there.
- **Some Backspace and Delete presses move a block into or out of a row.** Backspace at the start of the first column, or Delete at the end of the block just above the row, moves that block out, above the row, and removes every empty column in the row as well; when the row has two columns and the first holds only that block, the whole row turns back into ordinary blocks. Backspace at the start of the block just below the row, or Delete at the end of the last column, moves that block into the last column. Delete at the end of a block nested under the first column's last block joins the paragraph below the row onto it, skipping the other columns.
- **Selecting across columns and then deleting or typing removes the columns in between.** What is left of the first and the last selected column is joined in the first column, and an empty column takes the place of the rest: selecting from the middle of *aa* to the middle of *cc* in `[aa | bb | cc]` and pressing Backspace leaves `[ac | empty]`. Typing over such a selection does the same, with what you typed in between.
- **Copying part of a row and pasting it outside the row can make a new row.** When the copied part runs from one column into another, pasting it at the top level of a note gives a new row of columns rather than ordinary blocks. When you paste a paragraph, a row and another paragraph together, the first paragraph doesn't join the paragraph at the cursor, and pasting at the start of a paragraph that has text adds an empty paragraph.
- **Copying a row to another app flattens it in the plain-text copy.** The clipboard's plain text, which is Markdown, has the row's blocks one after another — the first column's, then the second's, and so on — with no image widths or table column widths; only the HTML copy keeps the row's column markers. An app that pastes plain text, such as a text editor, gets the flattened form.
- **Editing a row while someone else edits the same row can lose their typing.** When someone removes or flattens columns with the keyboard, presses **Ungroup columns**, or moves blocks by dragging or with Ctrl+Shift+↑ or ↓, what another person types in those places at the same moment can disappear or end up in a different paragraph. When two people at the same moment each delete one of the last blocks of the same column, leaving it empty, the whole row can disappear, other columns included. Being offline and reconnecting counts as the same moment. Dragging with ⋮⋮ has three cases of its own: when you drag a block beside another one to make a row, what another person types in the block you dropped next to at the same moment can disappear, or, when the new row is the last block of the note, appear in the empty paragraph added after it; when you drop a block into an empty column, what another person types in that column's empty paragraph can appear inside the block you dropped; and when you drag out the only block of a column, what another person types in that block can appear in the empty paragraph it leaves behind. A version saved before the edit can be brought back from version history.
- **Two people adding a column to the same row at the same moment can end up with five columns.** When two people each drag a block to the side of a different column of the same three-column row at the same moment, both columns are added and the row has five, over the four a drag allows. Both people see the same row.
- **A pasted row keeps all of its columns, even more than four.** `/grid` makes at most four columns, but a row with more columns pasted at the top level of a note is pasted as it is. Over MCP and the API such a row reads back with all of its columns, but writing it back unchanged is refused with `unsupported_columns`: a write takes rows of 2 to 4 columns.
- **Making columns by dragging is meant for a mouse.** On a touch screen, dragging a block beside another one isn't supported and may not make columns; make a row with `/grid` instead. Pasting a copied row, or part of a row that runs from one column into another, at the top level of a note also makes one.
- **Dragging a column's only block to the side of another block in the same row leaves an empty column.** The column it came from is not removed: it keeps an empty paragraph. Dragging the third column's block to the left edge of the first column of a three-column row gives four columns, the last one empty. Use **Ungroup columns**, or Backspace at the start of the empty column, to get rid of it.
- **The columns of a row of four can't be reordered by dragging.** The drop guide doesn't turn vertical on a row of four columns, so dropping at its edges is an ordinary move, even for a block that is already in the row.
- **Equal heights for pictures are worked out only when a drag makes the row or adds the column, and small pictures aren't enlarged.** A picture that is smaller than the width it would be given keeps its own width; the other pictures still come out the same height.
- **Holding Ctrl (⌥ on a Mac) while dragging a block copies it and doesn't make columns.**
- **Dropping a block beside another one has no keyboard shortcut.** Use `/grid` with the clipboard, or move the block with Ctrl+Shift+↑ or ↓.
- **Dragging an already-selected image by the picture itself doesn't make columns.** It moves like an ordinary block; take it by its ⋮⋮ instead.
- **In the gap between two columns, the guide you see can differ from where the block lands.** While a block dragged by its ⋮⋮ is held over the gap, the guide can be drawn at the top of the left column while the block lands at the bottom of that column.
- **While something is being dragged, the formatting toolbar and its own pop-ups don't accept drops.** Text dragged onto an open **Create link** or embed URL field can't be dropped into it.
- **Making an image in a column smaller doesn't make the column narrower again.** Dragging the image's handle past its column's width widens the column and squeezes the others; once you let go, making the image smaller again leaves the column as wide as it became. Drag the divider between the columns to narrow it.
- **An image can't be dragged wider once the other columns are down to their minimum widths.** The other columns give up width only as far as 200 pixels, or 96 for a column that holds only images; past that the handle stops.
- **Every divider between columns is a Tab stop, so a note with many rows needs extra Tab presses to get past them.** The dividers come after the note's text in the Tab order, one stop each, from the top row down.
- **When two people resize the same row at the same moment, one person's change can be lost.** Dragging a divider changes only the two columns next to it, so two people dragging different dividers keep both changes. When both drag the same divider, the row ends up as the person who lets go last left it. When one widens an image while the other drags a divider, letting go of the image last leaves the whole row as the image's user left it, while letting go of the divider last sets the two columns next to it and keeps the other person's widths in the other columns: a column not next to the divider is changed only if it still has the width it had when the divider was grabbed. When both let go before either has received the other's change (for example while one of them is offline), a column both of them changed keeps one person's value.
- **In a presentation, whether a row stacks is decided when its slide is shown.** That happens when the presentation opens, when you move to a slide, and when a slide's content changes. Resizing the window or switching to full screen doesn't redo it for the slide on screen; move to another slide and back, or reopen the presentation.
- **Columns in a presentation have no minimum width.** They keep the width ratio they have in the note, so in a row whose columns differ a lot in width, the narrow ones can get very narrow before the row stacks.
- **Rolling back to an earlier version deletes side-by-side columns, together with what is in them.** An earlier version doesn't know about columns, so the first time a note with columns is opened there, its rows and everything inside them are removed from the note, and the note is saved that way. Back up before rolling back.
## Version history
See [Version history](./versions.md) for how versions work.
- **Applying a version usually makes earlier AI edits impossible to revert.** A write in a note's **AI edit history** can be reverted only while the blocks it changed are exactly as it left them. Applying a version replaces the whole note, so that is usually no longer the case.
- **Ctrl+Z can't undo applying a version.** The applied content comes from the server, like an AI write, and Ctrl+Z only undoes your own edits. To go back, apply another version.
- **Applying a version overwrites changes that nobody has saved as a version, including other people's.** The dialog that appears when there are such changes says so, and **Apply without saving** discards them. If someone edits the note while the apply is under way, the apply can stop without changing anything, and the dialog appears again.
- **Everyone who edits a note between two versions is listed on the later one, without saying who changed what.** People and AI assistants whose changes fall within the same quiet stretch end up in one version — including an AI write and the edits made around it.
- **Comparing versions of a very large note takes a moment.** The comparison is worked out in your browser. On a note of 2,000 paragraphs, with the version history panel open, the time from clicking the note's first version to the first change marker appearing in the side-by-side comparison against the current state was 806, 787 and 771 ms in three runs (median 787 ms; headless Chromium under WSL2, a 1400×900 window, the end-to-end test stack), with the two columns already lined up block by block. Without a browser (jsdom), decoding the versions of such a note took a median of 53 ms and working out the comparison 11 ms. On the server, saving a version of a 2,000-block note by hand took 54, 70 and 65 ms, and fetching it about 4 ms (a 393,083-byte response), in an integration test against a local PostgreSQL 17.
- **Versions don't count toward storage limits, and there is no site-wide cap on them.** The retention settings in **Site admin → Storage plans** are the only control, and they thin out automatic versions only: manual versions stay until someone deletes them, the note is permanently deleted, or its history is cleared.
- **A block moved under a different parent block shows as deleted in one place and added in another.** Blocks are matched only among the same parent's blocks, and only blocks that change order there are marked as moved. Among the same parent's blocks, a block that the editor gave a new identity is still matched when its content is exactly the same — so text you push down by pressing Enter at the start of its paragraph isn't shown as deleted and added, as long as you don't also change it. Pasting over a whole note gives every block a new identity: blocks whose content is exactly the same are matched wherever they are, and a text block (a paragraph, heading, list item, checklist item or quote) of at least four words — or four characters, in Chinese, Japanese or Korean — whose wording stayed close is matched too, showing what changed inside it. Close wording is looked for only within about 30 blocks of where the previous such match was found, so after a run of more than 30 earlier blocks that were removed or rewritten beyond recognition, the rest of that list falls back to deleted and added. Other blocks, such as images, tables and code, and shorter text blocks are matched by content only when they are exactly the same. Empty paragraphs are matched with each other too, but are never marked as moved.
- **Side by side, the columns line up only block by block, at the top level.** Each row is as tall as the taller of its two blocks, so the shorter side leaves blank space below its block. Blocks marked as moved aren't lined up with their counterpart: each keeps its place in its own column, opposite a striped gap or whatever else isn't lined up there. The same goes for an empty paragraph matched with an empty paragraph elsewhere in the other version, when lining them up would cross other matched blocks. Blocks nested inside another block, such as sub-items of a list, aren't lined up one by one — only the top-level block they belong to is. When an image or other embedded content finishes loading after the comparison appears and makes a block taller, the columns are out of line for a moment until they are lined up again.
- **In the side-by-side comparison, a row of side-by-side columns lines up as one unit.** The columns inside it are not aligned one by one; each side shows the row as it is in its own version. In either comparison, columns are matched by their identity, not their position: when two columns swap places, one of them is marked as moved, and deleting a column and adding another one shows one deleted and one added column — in the single-column comparison, which shows both versions merged, the row then has more columns than either version. A block moved from one column to another shows as deleted in one and added in the other.
- **A row of side-by-side columns is marked a little differently from other blocks in a comparison.** A column whose width changed is marked as changed, with no **Changed · see before and after** button. Every column in a comparison gets 8 pixels of extra space on its left, where its marker goes. A column that was added or deleted is marked along with each block inside it, so those show two coloured bars next to each other.
- **Copying a note doesn't bring its versions along, and moving a note into a group or transferring a group's notes deletes their versions.** There is also no "before" version for older notes: the first time a note that existed before version history was added is edited, the content it had until then isn't kept as a version.
- **Applying a version to a note whose content is malformed fails with a server error.** This affects a note whose blocks are missing their ids, or are nested thousands of levels deep; AI writes to such a note fail the same way, as they did before version history.
- **Deleting a group and transferring its notes deletes all of their versions in the same database transaction.** In an integration test against a local PostgreSQL 17, transferring 50 notes with 3 versions each took 81, 83 and 92 ms in three runs (median 83 ms).
- **Changing an Automatic versions switch reaches a note that is open right now only after everyone has closed it.** This applies to all three switches (the site's, a personal space's and a group's).
- **If automatic versions are off for a note when its waiting changes come due** (X minutes after the last change, set in **Site admin → Storage plans**, 5 by default), **those changes stop waiting.** For a note that is open at that moment, what counts is how the switches were set when it was opened (or last moved); otherwise, how they are set at that moment — so for a note nobody has open, turning a switch off and on again before then doesn't make them stop waiting. Once the changes have stopped waiting, no automatic version is saved for them on their own, even after automatic versions are turned on again, and the people who made them aren't listed on a later version. The changes stay in the note, and a version saved later — by hand, or automatically after further edits — contains them.
- **When a version is applied at almost the same moment as someone else types, those few characters may not count as a change until the note is edited again**, so no automatic version is saved for them on their own until then.
- **If the server can't read a note's version settings when the note is opened, saving a version by hand answers that the server is busy until everyone has closed the note, and changes made in the meantime aren't, on their own, saved as an automatic version until the note is edited again after it is reopened.** Changes that were already waiting to be saved stay waiting: they are saved once the X minutes are up, but not before everyone has closed the note. Separately, changes saved in the moment right after a note is moved into a group or transferred, while its version tracking is being set up again, aren't counted as a change until the note is edited again — whether or not anyone leaves the note.
- **Deleting a version at the moment someone applies it leaves the note without a base version.** The content is still applied, but **Current state** reads **No base version**. No version is saved straight away; with automatic versions on, the next change to its content leads to an automatic version once the note goes quiet.
- **Deleting a version at the moment someone else's apply of that version finishes can leave the note based on a version that no longer exists.** **Current state** then shows that version's made-up name and code followed by **(deleted)**, until a new version is saved or another version is applied.
- **An automatic version appears a little after the X minutes are up.** Changes are stored on the server about 2 seconds after the last keystroke (at least every 10 seconds while someone keeps typing; an AI or API write is stored at once), and the server looks for notes due for a version every 30 seconds, so a version normally appears within about half a minute after the X minutes are up. When more than 100 notes are due at once (for example right after a restart), each further 100 wait one more 30-second round, and a check still running when the next one is due makes that one skip a round.
- **If the server fails to record a change and the note isn't changed again, that change isn't saved as an automatic version.** Saving a version by hand still works.
- **Someone whose edit is saved at the very moment an automatic version is being made may be listed on the wrong version.** If the note is open, they are listed both on that version and on the next one (the next one only if the note changes again); if it isn't open, they aren't listed on that version, and are listed on the next one only if there is still something new to save when it comes due (for example because the note changed again). This affects only the list of names, not the content.
- **A note that keeps failing to be saved as an automatic version is retried every 10 minutes, with at most one warning in the server log per try.** A note due to be saved whose version settings couldn't be read when it was opened is checked again every 30 seconds, without further warnings, until everyone has closed it.
- **Saving a note's content also works out a fingerprint of the note, to tell whether the content really changed.** On a note of 2,000 paragraphs this took 3.1 ms (median of 21 runs; Node 24.14.0 on Windows, outside the test stack).
- **Made-up version names repeat.** There are 4,096 of them, so two versions of a note can share one; the short code is what tells them apart. The word lists are fixed for good: changing them would rename existing versions.
- **When the version that another version is based on has been deleted, the deleted version's own name isn't shown**: it appears with its made-up name and code followed by **(deleted)**. A base that had already been deleted before this release started recording the ids of bases shows as **a deleted version**, and so can some bases after a rollback to 0.10 (see the next item).
- **After rolling back to 0.10 and upgrading again, some bases are shown wrongly.** 0.10 doesn't record the id of the version a note or a version is based on. For a note whose base changed while on 0.10 — a version was saved or applied there — **Current state** can name the base the note had when the site was rolled back, or read **Based on a deleted version**, until a new version is saved or a version is applied after upgrading again. A version saved on 0.10 on top of an older version shows as based on **a deleted version** for good.
- **If saving the applied content, or clearing the waiting changes, fails while a version is being applied**, the changes made before the apply stay waiting, and the applied content may later be saved as an automatic version listing the people who made those earlier changes.
- **Moving a note into a group, or deleting a group and transferring its notes, can fail with "The server is busy" while the background cleanup of old versions is working on those notes.** Try again.
- **While a notice is showing at the bottom of the screen (for about 5 seconds), the first Esc closes the notice rather than the dialog that is open.** Press Esc again to close the dialog. This applies throughout the app.
- **Pressing Esc in the sidebar's search box while previewing a version also closes the preview**, besides clearing the search.
- **Applying a version from before the upgrade to 0.11 brings back ordinary links, and the one-time conversion doesn't convert them again.** That includes the version named `連結轉換前` that the one-time conversion saves. This is deliberate: it is the way to undo the conversion for one note. Links you paste, drag in, type or write afterwards are converted as usual (#269).
- **In a comparison or the AI box's preview, a link to a note you can't see can show as removed and added again inside a block that changed for another reason.** Such a link with no text of its own is shown with the text it was made with, and the two versions can hold different such text. A block where only the text a link was made with differs isn't marked as changed (#269).
## Folders
- **Folders nest up to five levels, but giving a deleted group's notes to an admin, or restoring notes from the trash, can make a deeper tree.** Renaming and deleting folders in such a tree still work; no folder can be added below the fifth level.
- **Undoing a folder deletion, or restoring its notes from the trash, re-creates only the folders on the way to a note you restore.** A folder with no notes in it or below it doesn't come back.
- **How the notes in a folder are sorted is remembered only in this browser,** not in your account; other browsers and devices show the default, **Last modified**.
- **Sorting by Size uses the size of a note's editing state plus its attachments,** so a short note that has been edited a lot can sort above a longer one.
- **Changes someone else makes to a group's folders show up when you come back to the window or reload the page,** not right away.
- **Moving many notes and folders at once within a space stops after about 600 in a minute — sooner if you've made other changes to folders in that minute.** Each note or folder moved within its space — with **Move to…** in a note's `⋮` menu or **Move to** in the sidebar's selection mode — and each one moved back with their **Undo** counts against a per-person limit of 600 changes to folders a minute, which creating, renaming and deleting folders also count against, in the web app or with MCP's `create_folder`, `rename_folder`, `move_folder`, `move_note_to_folder` and `delete_folder`; deleting a folder counts as one, however many notes it moves to the trash. It is separate from the limit of 30 writes a minute that copying and AI edits use (see "Copying many notes at once from the sidebar…" above): moving notes doesn't use that one up. A larger selection stops partway with "Too many requests. Please slow down.", and so can its **Undo** right after the move; the message says how many items were moved, or moved back, and after a stopped move the item it stopped at and those after it stay ticked. Dragging a note to another place in the sidebar counts as one of those changes, and its **Undo** as one more. Making a folder by dropping a note on another note counts up to 7 when it works — up to 5 tries at creating the folder, when the name it picked turns out to be taken, and one for each of the two notes — and its **Undo** up to 3 more: one for each note moved back, and one for deleting the folder, which isn't counted when the folder is kept because, in **My notes** or **Groups**, notes outside the trash were already in it. Renaming it in the **Rename folder** box that opens after it counts one more each time you save a changed name; pressing **Save** with the name as it was counts nothing.
- **Dragging notes in the sidebar needs a mouse or a similar pointer.** The sidebar takes over a drag only when the browser reports that the device's main pointer is precise and can hover, as a mouse is; where the main pointer is a touch screen, as on most phones and tablets, dragging a note — with a long press too — doesn't move it. Only notes can be dragged, one at a time: dragging a folder doesn't move it, and in the sidebar's selection mode, where several notes can be ticked, dragging doesn't move notes. A note can be dropped only within its own space — **My notes**, **Shared with me**, or in **Groups** its own group.
- **Making a folder by dropping a note on another note isn't all or nothing.** The folder is created first and the two notes are then moved into it, each with its own request. When one of those fails, the app tries to move back what it moved and to delete the new folder if it is still empty, but if that fails too — for example when what failed was the limit of 600 changes to folders a minute — or the tab is closed partway, an empty folder, or one with only one of the two notes in it, can be left. Its **Undo** moves both notes back and then deletes the folder only if nothing else is in it (in **My notes** and **Groups**, notes in the trash don't count); otherwise the folder stays, with the message "The folder has other items now, so it was kept".
- **Dragging can't be done with the keyboard or a screen reader.** Move a note with **Move to…** in its `⋮` menu or **Move to** in the sidebar's selection mode instead; to put two notes in a new folder, create the folder first — **New folder**, or **New subfolder** in a folder's `⋮` menu — and move both notes into it.
- **When a deleted group's notes are given to an admin and the group's name is nothing but control characters, the new folder is named 群組 when the browser's preferred language is Chinese and Group otherwise** — the browser's own language setting, not the language picked in Knotebook's menu.
- **Folder names in the same place are unique ignoring letter case; for letters outside ASCII, what counts as the same letter depends on the database's locale.**
- **In a narrow sidebar, deeply nested folders and notes can lose their names to the indentation, have their right-hand side cut off by the sidebar's edge, and show a folder row's icons on top of its buttons.** On a wide screen, widening the sidebar fixes it; the sidebar on a narrow screen has a fixed width.
## Keyboard shortcuts
- **On a keyboard where `/` needs Shift, Ctrl+/ may not open the shortcuts page while the cursor is in a note.** A German layout, for example, types `/` with Shift+7, so Ctrl+/ reaches the editor as Ctrl+Shift+7, which the editor takes as "turn this paragraph into a numbered list": the paragraph with the cursor becomes a numbered list item and the page doesn't open. Outside the text of a note, and from **Keyboard shortcuts** in the user menu, it works as usual. This was seen in Chromium with the key sent the way such a layout sends it; it hasn't been tried on a real German keyboard.
- **Whether ⌘/ and ⌘, reach Knotebook in Safari, Chrome and Firefox on a Mac hasn't been checked.** The shortcuts were tried only in Chromium on Linux (with Ctrl). If a browser keeps one of them for itself, it won't open the shortcuts page or the settings there; the user menu items still work.
## Sending notes back as files (MCP bulk transfer)
- **Only a note with nothing in it at all counts as empty.** Sending a file back to a note with no blocks replaces its content with the file's. A note whose only block is an empty paragraph also downloads with a blank body, but sending text back to it puts the text in front of that paragraph, so the note ends with an empty paragraph.
- **A block the file didn't change can still be replaced when the file changes a large part of the note.** When a file changes so much that the server can't line up every line, it matches the unchanged parts by lines that appear only once. Blocks made only of lines that also appear elsewhere in the note, such as repeated `***` dividers or the same short paragraph, are still matched when unchanged text next to them is; they may fail to match only when everything around them changed too, and are then written back with the file's copy, with new ids.
- **When a note has the same paragraph twice and the file keeps only one, the copy kept isn't always the one you meant.** If the two are next to each other, or everything between them is removed too, the first one is kept. Otherwise the one kept is the one that fits where the file's other paragraphs put it.
- **Moving a long stretch of paragraphs in a file is written back as removing and re-adding the paragraphs on the shorter side of the move.** Those paragraphs get new ids.
- **An assistant tricked by a prompt injection can send every note you can see off-site.** `open_notes_as_files` gives an assistant a token that downloads, with one command, every note you can see — your own, the ones shared with you and your groups' notes, up to 3 000 — as files on the machine it runs on, where its shell commands can send them anywhere. With `notes:write`, it can also change, one file per request, any of those notes your role lets you edit, and create notes. A token lasts at most 30 minutes, its range can be narrowed with `space`, `group_id` or `folder_id`, and it stays out of URLs; none of that prevents it.
- **Text someone is typing can be lost when a file replaces the block it is in.** The server refuses a file when the note has changed since the download, but it only sees what has reached it: what someone was typing into a block in a browser and that hadn't reached the server yet when the file was written can be lost if the file replaces or removes that block.
- **A download is not a snapshot.** Each note is read when its turn comes, so the notes in one download can be from different moments, and a note moved to the trash, out of the range or out of your sight while the download runs is left out and listed in the manifest's `skipped`.
- **A download that nobody reads holds up every other download on the site.** Only one download runs on the whole site at a time, and the server has no time limit on it: a client that starts a download and then neither reads it nor closes the connection keeps that one place, so everyone else's downloads answer `429` until that connection drops or the server restarts. Only someone with a valid bulk transfer token can do this. A later version will have downloads wait their turn on the server instead (#396).
- **Revoking a bulk transfer token, or the credential that asked for it, doesn't stop a download that is already running right away.** The server checks the token and your account only before each batch of 50 notes, so the batch being written when you revoke still arrives, and a download of 50 notes or fewer can arrive whole.
- **A downloaded copy can be sent back only while the server still keeps what it needs of it.** For each person and note, the server keeps the 10 most recently downloaded copies, and removes a copy that hasn't been used for 30 days when a note is next downloaded. Sending back a copy it no longer has answers `409 base_unavailable`; download the note again.
- **A downloaded copy carries its changes back only once.** After a file's content has been written, sending the same copy again with other changes to the text answers `409 base_unavailable`; download the note again and change the new copy.
- **A note that has changed since the download refuses every changed file.** Any change to the note's title or content after the download — even in another part of the note — makes a changed file answer `409 base_unavailable` with `reason: "note_changed"`, and nothing in the note changes; download the note again and redo the change. That includes changes that leave the text the same: formatting or a color, a block replaced by an identical one, or typing into an empty paragraph and deleting it again. A note that had nothing in it at all when it was downloaded and now has a single empty paragraph — what opening it in the browser leaves — counts as unchanged. In a note marked `mergeable: false`, which can only be renamed with a file, the note is compared as it downloads, so a renamed linked note, or a linked note that went out of your sight, counts as a change there too.
- **Two new notes made from the same file within 30 minutes are one note.** A file with no `id` creates a note, and sending exactly the same file again within 30 minutes returns that note instead of creating another, which is what makes resending safe. So two notes made from one template, without changing either copy, end up as one note.
- **Moving, renaming or deleting a downloaded file changes nothing on the server.** The note a file goes back to is decided only by the `id` in its front matter; to rename a note, change `title` there. A file left out deletes nothing.
- **Attachments aren't in the download.** Images and files stay links (`/api/uploads/<id>`) in the markdown; fetch an image with `create_transfer_token`.
- **A path in the download can be longer than Windows' default limit of 260 characters.** Each folder and file name is cut to 64 characters, but nothing limits a whole path.
- **Windows' own `tar.exe` skips notes whose file names it can't represent.** `C:\Windows\System32\tar.exe` leaves out files whose names have characters the system's code page can't represent — on a system using code page 950, for example emoji, simplified Chinese characters, Korean and Japanese kana — and exits with an error. Unpack with the `tar` in Git Bash or WSL, and compare with `.knotebook/manifest.json`. PowerShell's `Expand-Archive` only unpacks `.zip`, and the download is only offered as `.tar.gz`. How the download unpacks on a Mac hasn't been checked.
- **A file that changes many places can push older rows out of a note's AI edit history.** Each place a file changes is one row, and a note keeps its 100 most recent rows; a file that changes more than 100 places keeps only its own latest 100, and the reply's `editIds` lists only those.
- **Files sent back have a budget of their own, separate from the token write budget.** A credential's 60 writes per 10 minutes (the default) doesn't cap files sent back with its bulk transfer tokens; those count against 30 files per minute per person (the default), and a file the same as the copy it was downloaded as counts against the 300-reads-per-minute token budget (the default) instead, even when the 30 are used up. A file whose text differs counts against the 30 before the server parses it, so a file refused with a `400` for what it contains still counts; one that turns out to change nothing, such as a file that only adds blank lines, is given back — but once the 30 (or the lower number a site admin set) are used up, it too is answered `429` (unless an earlier check refuses it first, such as a note that has changed since the download), because the server can only tell that it changes nothing by parsing it.
- **A copy whose changes were reverted is no longer accepted at all, not even to rename the note.** Once any of the AI edit history rows a file wrote has been reverted, sending that copy again — unchanged, or with only a new `title` — answers `409 base_unavailable` with `reason: "reverted"` instead of `unchanged` or `renamed` (with other changes to the text it answers `already_sent`, as any copy already sent back does). To change the note, download it again.
- **A file near the size limit may not fit once it is a note.** A file sent back can be at most 262 144 bytes. The file the note downloads as afterwards can be a few bytes longer than the one sent, and then goes over the limit; the manifest marks such a note `tooLargeToSend: true`, and it can only be changed with `edit_note`.
- **A viewer's file is refused even when it is unchanged.** Sending back any file for a note your role can only view answers `403`, whether or not the file changes anything; a file that isn't UTF-8 or whose front matter is malformed answers `400 invalid_body` first.
- **Spaces at the start of a note's title stay in its file name.** A file is named after the note's title with spaces and dots at the end removed, but spaces at the start are kept.
- **Blocks nested under a list item come out of it when a file changes that item.** A list item with a paragraph nested under it is one item in the file; when the file changes it, the paragraph comes back as a block of its own, after the item.
- **A toggle becomes a bullet when a file changes it.** The file has a toggle as a bullet item, and that is what comes back. `edit_note` does the same with a toggle read with `read_note_section` and written back.
- **Some blocks come back different when a file changes the part of the note they are in.** Their markdown doesn't read back as the same block, for example: a relative link with non-ASCII characters and no scheme is lost, runs of spaces and spaces at the end of a line are collapsed, a code block with no language becomes `text`, an empty checklist item becomes a bullet, and a file block becomes a paragraph with a link. Blocks in parts the file didn't change aren't affected.
- **A list with colored items is one block in the file.** A run of list items that includes colored items is written as one block of HTML, so changing one item replaces the whole run, with new ids.
- **A few notes can't be written back as files.** When a note's blocks can't be lined up with its markdown at download time, the manifest marks it `mergeable: false`, and a file that changes its content answers `409 not_mergeable`; change it with `edit_note`. Sending it back unchanged, or with only a new `title`, still works.
- **An empty paragraph can't be added by adding a blank line, and removing blank lines removes empty paragraphs.** A file that only adds blank lines changes nothing; a file that removes the blank lines an empty paragraph stood for removes that paragraph.
- **If recording a file's changes fails after they were merged, the note is changed with no row in its AI edit history.** The reply is `500`. Sending the same file again answers `409 base_unavailable` with `reason: "note_changed"` — the note now has the file's changes — and doesn't apply them a second time; download the note again to check it.
- **HTML comments in a file are dropped, and can turn the lines below them into plain text.** Text after a comment, on its line and on the lines right below it, becomes plain text that can run on into the text after a later comment; a blank line after each comment, with nothing after it on its line, keeps the lines below it as markdown. A file that changes a part into only comments removes it, and a comment added on a line of its own, with blank lines around it, changes nothing. A comment opened with `<!--` and not closed can hide the text after it; when that leaves a changed part with nothing in it, the whole file is refused with `400 empty_content`. `edit_note` parses markdown the same way.
- **A code block a file opens and never closes gets the whole file refused.** When a line the file added or changed opens a code block (```` ``` ```` or `~~~`) that runs to the end of the file, the file is refused with `400 invalid_body` and nothing is written; close the block, or use inline code. A note that has a paragraph whose text is ```` ``` ```` can still be sent back, as long as that paragraph isn't changed.
- **The check for an unclosed code block has gaps.** It doesn't notice an unclosed `<pre>`; it can refuse a file that has a code block after a `<textarea>` or `<script>`; in a note with two paragraphs whose text is ```` ``` ````, it can let such a block through; and whenever the file adds or changes a line that looks like the start of a code block, the whole file is parsed for it, which can be slow for markdown that is slow to parse, such as long runs of `*a _b`.
- **An indented line after a blank line can read differently when a file changes the part it is in.** Under a list item, an indented line after a blank line belongs to the item when the whole file is read, but can come back as a block of its own when a file changes the part it is in.
- **After `APP_SECRET` changes, every downloaded copy answers `409 base_unavailable`.** Download the notes again. A new-note file sent again after the change creates another note.
- **Changing only the spaces at the end of a line doesn't write anything.** That includes no-break and ideographic spaces; inside a code block too.
- **Changing where a numbered list starts doesn't write anything.** A file that changes `5. a` to `1. a`, or the other way round, changes nothing; use `edit_note`.
- **A paragraph whose text looks like HTML can become something else.** The download escapes text the way reading a note does (see [AI editing](./ai-editing.md#markdown-round-trip)): characters Markdown reads as formatting, and block markers at the start of a paragraph or a quote, get a backslash — a paragraph that reads `# x`, `- x`, `> x` or `***` is written `\# x`, `\- x`, `\> x` or `\*\*\*` — and a line break that Markdown would misread is written `<br>`, so such text comes back as it was when a file changes the part it is in. `<` is not escaped (#379): text such as `<tag>` comes back as markup, an unknown tag is dropped, and a paragraph whose text is an HTML comment is dropped.
- **A copy of a `mergeable: false` note downloaded before #386 was fixed can't send back a new title.** For such a note, the server checks that the note hasn't changed by writing it out the way a download does now and comparing that with the copy; a note with text that #386 changed how it is written (characters that are now escaped, or line breaks now written `<br>`) no longer matches, so sending back that copy with only a new `title` answers `409 base_unavailable` with `reason: "note_changed"`. Download the note again.
- **Reverting a removed part and an added part at the same place can put them back in a slightly different order.** When a file removes blocks and adds blocks right after the same unchanged block, reverting their rows puts the removed blocks back close to, but not always exactly at, where they were.
- **Don't revert the AI edit history rows of files sent back over MCP while v0.14 runs.** In v0.14, a `file_sync` row for a part a file rewrote or added can show a **Revert** button, and reverting it reports success but puts nothing back; the row is then marked as reverted, so it can't be reverted after upgrading again either. To undo such a write, upgrade again first, then revert it. A `file_sync` row for a part a file only removed shows as not revertable in v0.14. See [Upgrading and rolling back](./self-hosting.md#upgrading-and-rolling-back).