Files
verdande/NOTES-SPEC.md
T
Kristian WindandClaude Opus 5 e29fd04abd docs: noternes opbygning, skrevet til at blive løftet ud
Hvordan noter er bygget her: modellen, syntaksen, søgningens tre lag,
editorkontrakten, vedhæftninger, ind- og udførsel — og begrundelserne, som er den
del, der ikke kan læses ud af skemaet.

Den begynder med fjorten spørgsmål, der skal besvares før der skrives kode, og
siger det til den, der læser den, med rene ord: gæt ikke en standard. Grunden
står i filen — omtrent en tredjedel af designet her er arvet fra de omgivelser,
noterne bor i, og løftet ind i et program med andre omgivelser uden at afgøre
hvad der skal med, giver noget, der virker en uge og derefter slås med én.

Den siger også, hvad der ikke virker: notekroppe i klartekst i hver
sikkerhedskopi, import der aldrig fletter, ingen sync. En specifikation, der kun
beskriver det, der virker, lyver ved fortielse.

I repoet frem for uden for det, som HANDOFF.md og NOTES-PLAN.md er. De to er
arbejdspapirer om *dette* program; den her er beskrivelsen af en del af det, og
den bliver kun forkert, hvis koden flytter sig uden at den følger med — hvilket
er lettere at opdage, når den ligger ved siden af.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:02:59 +02:00

19 KiB
Raw Permalink Blame History

Notes, as verdande builds them

A description of one working notes system: the data model, the syntax, the search, the rendering contract, the import and export formats, and — more useful than any of it — the decisions that look arbitrary until you know what they cost.

It is written to be lifted into another program. Nothing here depends on Go, SQLite or Svelte except where it says so; where an answer was forced by the tool, that is called out so you can pick a different one on purpose rather than by accident.


Read this part first: what to ask before you build

This document is not a drop-in. It describes a notes system that lives inside a task app with projects, roles, a trash and a search box, and about a third of its design is inherited from that. Lifting it into a program with different surroundings without deciding which parts to keep produces something that works for a week and then fights you.

If you are an assistant working from this file: ask these questions and get answers before writing code. Do not guess a default. Each one changes what you build, not just how you build it.

About the surroundings

  1. Does the host app already have a place to file things — projects, folders, notebooks, tags? verdande deliberately gave notes no hierarchy of their own and filed them under the projects that already existed. If your app has nothing, you need an answer for where a note lives, and "a folder tree" is the expensive one.
  2. Is there more than one person? Sharing, roles and "who wrote this" run through the whole model below. Single-user changes the schema and removes about half the permission surface.
  3. Is there already a trash, and does it expire? Notes lean on one. Without it, deleting is either permanent or you are building a trash.
  4. Is there an existing full-text index? The search here is FTS5-shaped. On Postgres it is tsvector, on a client-side app it is probably a scan, and the ranking section below changes accordingly.

About the notes themselves

  1. Markdown, rich text, or blocks? verdande stores Markdown and renders it. That decision is defended below; it is also the one that most constrains everything else.
  2. Is the first line the title, or is there a title field? Both work. Deriving it means one less thing to keep in step and one less thing a person can leave blank, and it forces the rule "the first line is a heading" on the editor.
  3. What links to what? #project, [[note]] and task references exist here because the app has projects and tasks. Yours may have none of them, or others.
  4. Do notes carry files? Attachments bring content-type handling, a store, an authorisation path and an XSS surface. Skipping them is a legitimate answer.

About scale and language

  1. How many notes, realistically? Several choices below flip at around ten thousand: the list payload, the search fallback, rendering the whole list.
  2. Which languages? The folding described under Search is Danish. Every language has its own version of that problem and the generic answer (strip diacritics) is wrong for several of them.
  3. Where does the text end up besides the screen? Search indexes, AI features, exports and backups each read it. Plaintext makes all of them cheap and makes a stolen backup expensive — see What is still open.

About integration, specifically

  1. Who owns the note's identity — you or the other system? If notes will sync with something else, ids and timestamps are the whole problem, and the updated_at rule below becomes the first thing you have to change.
  2. Is this a read of somebody else's notes, or the store of record? A copy needs conflict rules; a store of record needs export.
  3. What happens to a note whose project, task or link target is deleted? Answered here as "the link goes dead and the note is untouched". If your host app cascades instead, say so before the first delete.

The shape of a note

id           an opaque string, unique
project_id   nullable — where it is filed, or loose
title        derived, never set by a caller
body         Markdown, the whole note
created_by   nullable — survives the account being deleted
pinned       favourite; the list puts these first
archived_at  nullable timestamp — put away, not thrown away
deleted_at   nullable timestamp — the trash
created_at
updated_at

Markdown in a column

Not rich text, and not a format of our own.

Export was a requirement before a line was written, and if the stored form is the exported form there is no conversion left to lose anything in. A note stays readable by any editor on the day the program is gone. It is also what makes search and anything an AI does over notes cheap: both read text.

The cost is real and you should know it before choosing: everything the editor can do has to survive a round trip through Markdown, and anything Markdown cannot say, the editor must not offer. There is no place to hide a feature.

The title is the first line

Always, and re-derived on every save — not "when it is empty".

Derived once and then left alone, a title goes stale the moment somebody rewrites the opening of a note, and the list ends up calling a note by a name that is nowhere in it. The column is a cache of the body, not a field beside it, and no caller can set it, so every caller gets the same answer.

This forces one editor rule: a new note opens on its title, and the first line is styled as a heading. Otherwise a note whose first line is a sentence is a note named by its first sentence.

Archive and trash are two different bits

The trash says this was a mistake, and in thirty days it is gone. The archive says this is finished, and I do not want to read past it.

A twelve-hundred-note import is exactly the case where most notes are worth keeping and almost none are worth looking at. Without an archive, the list is the whole archive forever, and finding this week's note means reading past a decade.

Both are nullable timestamps rather than booleans: when was this put away answers questions a flag cannot, and it costs the same byte.


Filing: no hierarchy of its own

A note belongs to a project, or to nobody.

The reasoning is worth repeating because it is easy to talk yourself out of: projects already exist, are already shared, already have roles, already have a trash. A second hierarchy to file things in is one too many, and a note filed under a project inherits every one of those answers instead of needing its own.

  • A note with a project: everybody who can read the project can read the note; an editor can change it.
  • A note without one: the author's, and nobody else's.

Sharing a note is filing it in a project. There is no access list of its own — a second way to give somebody access is a second place to get it wrong, and the two would disagree the day one of them changed.


The body is the truth. Links are an index over it, rebuilt on every save.

Syntax Means Notes
#project a project Same characters the task quick-add parser uses, on purpose
[[a note]] another note, by title The spelling every note app has settled on
/opgave/<uuid> or task:<uuid> a task What a link pasted out of the app carries

Patterns as used here:

project   (?:^|\s)#([\p{L}\p{N}_-]{1,64})
note      \[\[([^\]\n]{1,200})\]\]
task      (?:/opgave/|task:)([0-9a-fA-F-]{36})

Three decisions inside that table:

  • #Firma in a note means the same project as #Firma in the task box. Not a second kind of tag that happens to look alike. That is the whole of "tag a note and see it in the project".
  • The link table is torn down and rebuilt on save, not diffed. A body is small and the set is small; a diff is a second description of the same fact, and it can disagree with the text. That is the one thing this must never do.
  • The target is not a foreign key. A link to something deleted is a fact about the note. It should read as a dead link rather than vanish, and it must not block the delete.

The index exists so the question can be asked backwards: what has been written about this task? Answering that by searching every note's prose would be too slow to leave a panel open on.


Three layers, in the order they answer.

1. Full-text, ranked

FTS5 over title, body and a folded copy, with unicode61 remove_diacritics 2.

Ranking is bm25 with the title weighted ten times the body. A word in the title is what the note is about; the same word once in a long body is a mention.

This matters more than it sounds. Ordering by "last touched" is invisible with ten notes and useless with twelve hundred imported the same evening: searching "Tesla" answered with a note about nginx first and the note actually called "Tesla Model Y" second — which reads as a search that does not work, because from where the person is sitting it is one.

2. Folding, for the language you are in

remove_diacritics does not touch ø, æ or å — Unicode considers them letters in their own right, not accented forms. So a Dane cannot find "grøn" by typing "gron", which is how people type when the keyboard is not theirs.

The answer here is a generated column carrying a transliterated copy:

ø,Ø → o     æ,Æ → ae     å,Å → aa

indexed alongside the body and weighted the same as the body — otherwise a folded match outranks an exact one.

Ask, do not copy. This mapping is Danish. German wants ü → ue, Swedish disagrees with Danish about ä, and Turkish has a dotless ı that breaks naive lowercasing entirely. Get the mapping from somebody who types the language.

3. A word inside a word

An index is built of tokens, and a token is whatever the tokenizer decided was a word. gamerule keepInventory true is three of them, and the middle one is keepinventory — so a search for "keep inventory" asks for two words that are nowhere and gets nothing, while the note sits right there.

A prefix query gets half of it: keep* matches, inventory* cannot, and the two are ANDed, so the half that works is thrown away by the half that cannot.

This is not exotic in a notes app. A decade of notes is full of commands, identifiers and file names, and every one of them is several words the index thinks is one.

So: the index answers first; when it has nothing, the text is read, and every term must appear somewhere in the note, in any word. A LIKE '%term%' over the folded column covers title, body and language folding at once.

What it costs: a leading % cannot use an index, so it scans. At twelve hundred notes that is a few megabytes and a millisecond or two, and it only runs when the fast path already came back empty — which is exactly the search that was about to disappoint somebody. At a hundred thousand notes it is the wrong shape, and the answer then is a trigram index, not a bigger scan.

It does not rank. There is no match to rank — that is the whole situation.


The list, and why the body is empty in it

A list showing a title and ninety characters of preview must not fetch whole notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could see.

The important half is how it is trimmed: the body is emptied, not shortened.

A field called body holding a piece of the body is a trap. The editor opens it, the autosave pause writes it back, and the note is now cut down to its own excerpt — destroyed by nothing more than having been looked at. Send preview as its own field and leave body empty; the client sees the empty body and fetches the whole note.

That costs one more separation, and it is not optional:

Which row is selected and which note is loaded are two different things.

The editor loads its text when the note's id changes. Hand it the excerpt first and the whole note second and the id is the same both times — so the second one never renders, and somebody sits looking at a note that had a title a moment ago.

Highlight the row immediately from the list; give the editor the note only when it is whole.

Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS content-visibility: auto with an intrinsic-size guess took it to 0.95 s with no virtual list, no scroll maths, and no list that can fall out of step with itself. Try that before building windowing.


The editor contract

Rich text on top, Markdown underneath, and a source view that shows the file.

The supported subset — deliberately small, because everything in it has to survive the round trip:

Block Markdown
Title #
Heading ##
Subheading ###
Quote >
Code block ``` with an optional language
Bullet and numbered lists - , 1. , nested

Inline: **bold**, *italic*, ~~strikethrough~~, `code`, <u>underline</u>, and images as ![alt](url).

Five rules that were each learned the hard way:

  1. Every line becomes exactly one block. Markdown normally joins consecutive lines into a paragraph. Here it must not: the person typed a line and expects to get it back, and an editor that silently reflows what somebody wrote is an editor they stop trusting.
  2. Load on id change, never while typing. Writing to the editor's HTML puts the caret back at the start, which mid-sentence is unusable.
  3. One definition of what a code block contains. A copy button inside the <pre> and a syntax highlighter reading textContent will disagree, and the difference ends up in the file — a click on "Copy" wrote the word Copied into the code and the next save put it on disk. Have one function that says what a block holds, used by the highlighter and the Markdown serialiser both.
  4. A note that cannot be rendered must not take the pane with it. One note arriving in an unexpected shape threw inside the editor's reactive effect, and from then on no note would open — the previous one just stayed on screen. Put a guard around the render, fall back to showing the raw text, and let the rest of the pane keep working.
  5. Escaping between tags and escaping inside an attribute are different jobs. An escaper written for text between two tags handles &, <, > — and not quotes, because between tags they are harmless. Inside an attribute they close it. ![" onerror="alert(1)](…) is the whole exploit.

Attachments

Images pasted or dragged into a note, stored content-addressed by hash, referenced from the body as ordinary Markdown images.

The security shape, which is the part worth copying:

  • Served as application/octet-stream with nosniff by default. Attachments are the one place a user supplies content another user opens. An uploaded SVG or HTML file rendered inline runs its own script on your origin with the session cookie attached.
  • One exception, as a named allowlist: a short list of raster image types served inline as their exact type — png, jpeg, gif, webp, avif, heic. A note carries its pictures in the text, and served as octet-stream every one of them is a broken image.
  • The list is named one type at a time, never matched on an image/ prefix. The prefix lets image/svg+xml through, and an SVG is a document that can run script.
  • The stored path is checked against the files directory again at read time. A traversal that somehow got into the database must not become one that reads /etc/passwd.
  • Reaching an attachment follows the note, which follows its project. Same chain as everything else. Do not let a client hand you the parent id and take it on trust.

Import and export

The same format both ways: a zip of Markdown files, one per note, with a YAML front matter block.

---
created: 2019-04-02T10:15:00Z
modified: 2024-11-30T21:04:00Z
---

# Møde med Anders

Han vil gerne have kaffe hver uge.
  • created / created_at / date and modified / updated / updated_at are all accepted on the way in. Obsidian and Bear write this block; so should you.
  • Images travel in the zip beside the notes, and the links in the text are relative — a note and its pictures must end up in the same archive, which matters the moment an export is split into parts.
  • File names collide: two notes called "Møde" are two notes, and a zip with one entry twice is a zip that loses one.

Limits that a real import taught, all of them cheap to add and expensive to omit:

  • A ceiling on the upload, on the number of entries, and on the unpacked size. 64 MB of zip expanded to 66 GB is a normal shape for an attack.
  • A per-file limit as well as a whole-archive budget: one enormous photograph and ten thousand small ones are different attacks with the same ending.
  • The entry name is never used as a path. Store by hash; the name is only a key for rewriting links.
  • Count what you skip, and say so. A note whose name contained an ellipsis was dropped silently by a filter looking for .. — one out of 1141, found only by counting at both ends.

Timestamps on the way in

An importer needs to write created_at and updated_at from the file, and the normal save path must refuse to let it.

updated_at means when did this program last write this note. Let any caller set it and the column means whatever the last writer felt like. Give the importer its own narrow entry point instead, and say in the comment why it exists.

Without those dates, a move of twelve hundred notes is a pile that was all written the same evening — and the order they were written in is half of what an archive is.


What is still open here

Written down because a specification that only lists what works is a specification that lies by omission.

  • Note bodies are plaintext at rest. They hold API keys and passwords, and they are in every backup anybody can download. Encrypting them is bigger than it sounds: the body is in the full-text index and the folded column is generated from it, so both are a second plaintext copy in the same database. Encrypt and both have to go, and search has to be rewritten to decrypt in application code — fine at a thousand notes, a full scan per keystroke at ten thousand, at which point the answer is a keyed index (an HMAC per word).
  • Import never merges. Every run creates new notes. Say so out loud in the UI.
  • There is no sync. Export and the API are the way out.

A short list of the traps, for the person in a hurry

  1. A body field holding an excerpt will be saved over the real one.
  2. Selected row and loaded note are two different pieces of state.
  3. The title must be re-derived on every save or it goes stale.
  4. Diacritic stripping is not folding, and folding is language-specific.
  5. A tokenizer cannot see a word inside a word; decide what you do about it.
  6. An attachment served inline is a script on your origin unless you allowlist by exact type and send nosniff.
  7. Escaping for text and escaping for attributes are different functions.
  8. One render failure must not be allowed to freeze the whole pane.
  9. An unpacked-size limit is not optional on an archive import.
  10. Let the importer set timestamps; let nobody else.