Skip to content

Two editions: verdande and urd

One program, built once, with two faces.

verdande urd
What it is Tasks, projects and notes Notes
VERDANDE_EDITION full (the default) notes
Routes served 200 137
Mark ᚹ ᚢ

Set VERDANDE_EDITION=notes and the instance is urd: the task routes are not mounted, the sidebar has no way to them, search answers with notes only, and the program calls itself urd in its tab, its wordmark and its web manifest.

Running the notes edition

There is one image. The edition is an environment variable on the container:

docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
    ghcr.io/kristianwind/verdande:latest

No second image and no rune of its own, deliberately.

The edition is for an operator who wants an instance that genuinely has no task routes — the routes are absent from the router, not hidden in the interface. It is not how urd is normally reached. That is the second door, below.

Two details that surprise people who look inside:

  • The binary is called /verdande and the database file is verdande.db, in both editions. Not an oversight: it is the same binary, and renaming the file per edition would be a migration hazard in exchange for nothing a user sees.
  • The icons for both editions ship in the one image. The server decides which to serve at /icon.svg, because Safari looks for apple-touch-icon.png by name and never reads the manifest, so renaming paths would have left iOS with the wrong mark.

The second door: two apps, one instance

urd is normally reached at /urd on an ordinary verdande. One instance, one database, one login, one set of links between notes and tasks — and two things you can install.

verdande urd
entry / /urd
manifest /manifest.webmanifest /urd.webmanifest
manifest id / /urd
tab and mark verdande, ᚹ urd, ð
database, account, login the same the same

An operating system does not install a URL, it installs a manifest, and the identity of an installed app is that manifest's id. Two manifests on one origin with different ids are two apps; two URLs sharing one manifest are one app with a bookmark. That is the whole mechanism.

The shell is served per door rather than patched by JavaScript after the page loads, because iOS reads the page's <head> when somebody taps Add to Home Screen — and Safari looks for apple-touch-icon by name, having historically ignored the manifest's icons altogether.

urd's manifest is built from verdande's rather than written beside it, so the fields the two genuinely share — the colours, the display mode, the language — come from one place and cannot drift. Six keys are changed: id, start_url, scope, the two names and the icons. An icon in the built manifest that urd has no file for stops the server rather than being passed through, because the symptom would otherwise be urd's install prompt quietly showing Verdande's mark.

The face is a preference, not a gate

Entering at /urd makes that window wear urd's face: its mark, its name, its tab. It is kept strictly apart from the notes edition in the code, and the distinction is the one thing worth reading twice:

what it means what follows
notesOnly the server has no task routes a guard — the interface must not fetch them, they answer 404
urdFace this window came in through the urd door a preference — the routes are there, the data is yours, only the name differs

Conflate them and the urd face starts hiding data rather than chrome: a notebook would stop showing its tasks because somebody chose a different entrance. A test asserts the opposite directly — the urd window still gets 200 from /api/v1/tasks.

The face lives in sessionStorage, not localStorage, and that is what lets the two apps be two apps: they share an origin, so they share localStorage, cookies and the service worker. sessionStorage is per window, and an installed PWA is its own window. It survives a reload, which it must — after the first navigation the address is /noter and nobody passes through /urd again.

One thing the two doors cannot have separately

Theme, typeface and text size are shared, because they are in the localStorage the origin shares. That was a deliberate choice: splitting them per face would mean migrating everybody's stored settings for a difference few people want.

Moving between the two, measured

A /data directory moves between the editions in both directions, losslessly. Same binary, same schema, same migrations — so this is not a feature that was built, it is a property that was checked:

step result
verdande creates a notebook, a note in it, and a task 1 note, 1 task
the same directory, VERDANDE_EDITION=notes needs_setup: false — same account. The note keeps its title, its body and its project_id. /api/v1/tasks → 404
the same directory, back to full the task is still there; so is the note

The ownership stayed at the panel's service account through all three.

So the way to move notes is to change the variable. GET /export/notes.zip is for getting notes into other programs — it writes one .md per note and carries the body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That is the right division of labour, because the database already holds those.

The task rows stay

An instance run as notes still has its tasks in the database; they are simply not served. Harmless on a fresh instance, dead weight on a converted one — and worth knowing before handing a converted instance to somebody else.

Why one codebase and not a fork

The two share everything that is hard: accounts, sessions, passkeys, sharing, invitations, the realtime socket, search, the backup format, the migrations. Notes are about a twentieth of the Go in this repository and reference no task type; tasks reference no note type. A fork would have duplicated the other nineteen twentieths in order to separate the one — and then every security fix would have needed applying twice, by hand, forever.

So the split is a configuration value read in one place, and the thing that keeps it honest is a test rather than a convention: TestNotesEditionServesOnlyItsOwnRoutes walks both routers, compares them against a written-down list of the 63 withheld routes, and fails in both directions — a route that appears in the notes edition and a route that stops being withheld are both a failure.

An unknown value refuses to start

VERDANDE_EDITION=note, Notes and none are all rejected at startup, by name, rather than being treated as "not notes".

The silent direction is the dangerous one here. An operator who sets up an instance for notes and mistypes the value would otherwise get every task route served on it, with no error anywhere and no way to tell from the outside.

What urd does not have

The 63 withheld routes:

what
29 tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views
8 the CalDAV server — every route in it reads or writes a VTODO
1 /.well-known/caldav, which is a claim that a CalDAV server is here
1 the ICS calendar feed, plus 2 for the token it is reached with
4 the task exports: tasks.ics, projects.zip, and a project's .csv and .ics
2 the Todoist and CSV imports, which create tasks
4 project templates — a template is a project plus sections plus tasks
5 sections, which group tasks: section_id is a column on tasks and not on notes
6 the AI task features: split, tidy the inbox, and the day's plan
1 the inbox hook, which pushes a line of text in as a task

Plus five of the nine MCP tools — search_tasks, create_task, update_task, complete_task and add_comment. The endpoint is still there; the tool list is shorter. A tool a model can call is a surface, and one that answers "there are no tasks" is worse than one that does not exist: the model tries it, gets an empty answer, and tells the person there is nothing to do.

Everything else is the same program:

  • accounts, invitations, passkeys and two-factor
  • projects — which in urd are what a note belongs to
  • per-note sharing with other people, independent of projects
  • search, over notes
  • export, of notes
  • the mail, calendar and MCP surfaces, minus their task routes and task tools
  • AI settings, ask, and the note actions

Still being decided

Four connectors are reachable in urd today and have not been settled either way, because each has a plausible notes reading and withholding them would be deciding what this product is rather than closing a hole: the mail-in address, IMAP mailboxes, Gmail, and Google Calendar. They currently file what they receive as tasks, so in urd they connect and then have nowhere to put anything. The same applies to one handler rather than a route: POST /api/v1/ai/notes/{noteID}/actions/apply creates tasks from a note.

The name

Verðandi and Urðr are two of the three Norns in Völuspá, and the poem has them cutting marks into wood — the act of writing something down. Verdande is what is becoming; Urd is what has become and was kept.

An instance started without the task routes is a different product to the person using it, so it says a different name. That name is read from the configuration in one place (Config.ProductName), which is why there is no build-time flag and no second frontend: the frontend is byte-identical in both editions, and the server renames the two files a browser reads before any JavaScript runs — the HTML shell's title and the web manifest.

The beacon counts them separately

Both editions report the same two values once a day — instance id and version — and nothing else; see The install count. The edition is not in the payload, so the count does not distinguish them.