The other half of the error log. That one is what broke; this is what was done,
and nobody could ask it: `ListActivity` takes one project id, and an
administrator is not necessarily a member of the project they need to look into.
Paged with a keyset cursor rather than an offset. The log is written to
constantly and read rarely, so a page fetched a minute after the one before it
would be shifted by everything written in between — one row shown twice and the
row behind it skipped, which is the single failure an audit log must not have.
Both halves of the cursor matter: the log is written in whole seconds, so a
timestamp alone cannot separate two rows written in the same one.
Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it
to `created_by`. A log whose rows vanish with the account they describe answers
"what has happened here" with everything except the part somebody had a reason to
remove. `project_id` keeps cascading on purpose: the trash is the undo, and past
it the deletion was deliberate.
The event names moved to `web/src/lib/events.js`, because two surfaces read the
same log now and two copies of nineteen strings is two places for them to drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every
task they had ever written — including in projects somebody else owns. The
interface said so in plain words before you pressed the button, which is a
warning rather than a fix. It was the last remaining way to lose other people's
work by accident.
SET NULL instead: the task stays and loses its author, the same shape
`assignee_id` and `completed_by` already have.
SQLite cannot alter a foreign key action, so the table is rebuilt — and with
foreign keys on, the drop in the middle of a rebuild is a cascading delete that
takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op
inside a transaction, so migrations now run on a connection of their own, and a
migration can opt out of enforcement with `-- verdande:rebuild-tables`.
`PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
dangling reference fails the migration rather than writing it.
The test fills a database at 0007, upgrades it, and counts. Without the marker it
loses a task, a label link and a comment — which is exactly what it is for.
The admin list now says two numbers instead of one: what disappears, and what
stays behind unattributed. They are different sentences and the second is a
reassurance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel's watcher reported four 5xx today — twice from one address at
11:49, twice from another at 10:45 — and there is no way to find out what
any of them were. verdande logs the real cause of every 500, but on a Rune
the log belongs to the container and the container is replaced on every
restart. So an operator is told that something broke and given nothing to
act on, which is the worst of both: alarming and useless.
Every 500 now writes a row: what the handler was attempting in its own
words, the error it hit, the method and path, who was asking, and the
request id that ties it back to the log line for whoever still has it. The
"what" is the part a status code cannot give — "list projects" and "move
task" are the difference between a fault you can find and one you can only
count.
Written best-effort and never checked. This is the path that is already
reporting a failure, and a diagnostic that can itself fail the request
would turn one broken screen into two.
Threading the request through `internal()` and `storeError()` touched 108
call sites across 19 files, which is the price of not having had it there
from the start. It is mechanical and the compiler checked all of it.
Kept for thirty days by the nightly sweep: long enough to answer "what
happened last week", short enough that a fault loop cannot fill the volume
the tasks live on.
Read at Indstillinger → Fejl, administrators only — it carries paths,
account names and the server's own error strings. The empty state says
"Ingen fejl registreret. Det er den rigtige tilstand", because a blank
panel should not read as a broken one.
The test drops a table and asks for the endpoint that reads it: the same
code path a disk error takes, and the row that comes back has to name the
operation, the account and the request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more things the database has been recording and the interface has
never shown.
**Who a task is waiting on, on the row.** Only when it is not you: your own
list would otherwise carry your own face on every line, which is a column
of one initial telling you nothing. The point of showing an assignee is
that it is somebody else.
That needed names, and a task carries only an `assignee_id`. `GET /people`
is the primitive: everybody you share a project with, plus yourself,
fetched once per session. Not per row, and not per project either — a list
would have to know which project each task belongs to before it could ask
for that project's members. Deliberately *not* the instance's user list,
which is `/users` and administrators only: a task list has no business
enumerating everybody with an account here.
It sits first in the meta line, because who changes what the rest of the
line means — a date on somebody else's task is their deadline, not yours.
**And the activity log.** Nineteen kinds of entry have been written since
the beginning — every project, section, member, task and comment event —
and `GET /projects/{id}/activity` has answered honestly the whole time with
nothing on the other end. There is a Historik panel on the project now,
loaded when it is opened rather than with the page, and the event keys
become Danish in exactly one place. An unknown key falls through to itself
rather than being hidden: a build that records something this table has not
learned should still show that it happened.
One test assertion had to change, and it is worth saying why. It matched
"males inden fredag" against a row whose title is "males inden" — quick add
read "fredag" as the due date — so it was really matching the title
concatenated with the metadata beside it. Putting the assignee between them
broke it. The assertion now names the task's own text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An invite sent with the wrong role could not be corrected. The only way
back was to remove the person and invite them again — and removing a member
unassigns every task they were responsible for, so fixing a dropdown cost
somebody their work. This is the other side of "an external user cannot
create tasks": the rule was right, the role was wrong, and there was no way
to say so.
PATCH against a membership that already exists, not the upsert AddMember
does. Somebody who is not a member is not silently made one: that is an
invite, it belongs to the invite flow, and doing it here would let a typo
in a user id add a stranger to a project without anybody being asked.
The owner is refused, with the real reason rather than the true but useless
one. Ownership lives on the project rather than in project_members, so the
plain answer would have been "not a member" — which is correct and tells
nobody anything. Ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom.
In the list, the owner's standing is text and everybody else's is a
dropdown. A disabled control would be a thing that exists to say no.
And the roles are finally in Danish. The API's words are `owner`, `editor`
and `viewer`, and they were being printed straight into a Danish page; the
list now uses the same three phrases the invite form beside it already did.
The smoke test drives both browsers, because the interesting part is that
the other one's permissions really change: a viewer is told the project is
read-only and has no quick-add box, the owner promotes them from the list,
and after a reload the box is there and the notice is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`projects.color` has been in the schema since the first migration, has
defaulted to 'graphite' on every row ever written, and nothing has ever
read it. There was no palette, no name-to-colour mapping, and the dot in
the sidebar was hardcoded to a border grey. So this is not "add a picker";
it is implementing colour for the first time.
Names, not hex values. A hex in ten thousand rows is a decision nobody can
revisit; a name is resolved when the interface paints and can be retuned
for a new theme or a new display. It is also the difference between a
database that stores a fact and one that stores a rendering. The set is
closed and refused at the edge — an unknown name would be stored happily
and painted as the default, which looks like a colour that did not save.
One palette across all five themes, and this is the one place in app.css
that is not redefined per theme. Everything else there is text, a ground or
a border and has to be measured against what sits behind it. These are
small solid marks beside a label that already carries the meaning: what
they owe is to be told apart, and to be visible on both a near-black and a
white ground, which is why all ten sit in the mid-tone band. It also keeps
them out of the trap the themes are in — a colour added to four blocks and
forgotten in the fifth looks finished and falls back silently. There is no
fifth block to forget.
The list is written down three times: Go for validation, colors.js for the
picker, app.css for the tokens that paint. So a test reads the other two —
the same small-scanner approach openapi_test.go takes to the spec — and
fails if the server accepts a colour nothing can draw, or the picker offers
one the server refuses.
Groups get a colour of their own (migration 0006) and projects inside a
group are indented. The indent is to the group's *name*, not to its dot:
the eye follows the text, and lining the projects up under the dot leaves
them looking a step short of where the heading starts. The drop line is
indented with them, so a drop inside a group reads as landing in the group.
The group's dot is the control as well as the preview — you press the thing
you are about to change, as on the project page. That was not the first
design: a third text action, "Farve", sat beside Omdøb and Slet, and those
carry `opacity: 0` rather than `display: none`, so they take their width
whether or not you can see them. A group called "Arbejde" rendered as
"ARBEJD". Moving the control onto the dot gave the name its room back.
The smoke test measures the indent at the content edge rather than the
element's, because the indent is padding and the box does not move — which
is how the first version of that assertion passed 12 against 12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README has promised "internal users are created by an admin" since the
beginning and there was no such thing. There are exactly two ways an
account has ever come into existence: the first admin at setup, and
somebody accepting an invite to a project. So the only way to add a person
was to share a project with them first.
The store has been ready the whole time. CreateInvite already documents
that "projectID may be empty, which invites somebody to the instance
rather than to a particular project", and AcceptInvite already has the
branch that grants no membership when there is none. requireAdmin has
existed in the middleware and was used nowhere. This wires those three
together and puts a page on them.
An invite rather than an account with a password the administrator picks.
A password typed here would be known to two people before it was ever
used, and would then have to travel to the second one by some channel
nobody has thought about. No name is asked for either — the signup form
asks the person, and a name an administrator guesses is a name somebody
has to correct.
Two refusals keep the instance administrable: the last administrator
cannot be demoted or deleted, and nobody can delete the account they are
signed in as. Both are enforced on the server; the interface simply does
not draw the buttons on your own row, because a button whose only purpose
is to be refused wastes a click to teach a rule. The last-admin refusal
gets its own error code, because "that already exists" is not what
happened and this is the one refusal a person needs explained.
Deleting an account is the only hard delete in this database, and building
the confirmation is what turned up how wide it goes. `projects.owner_id`
cascades, which is expected. But `tasks.created_by` cascades too — so
removing a colleague deletes every task they ever wrote, including in
projects somebody else owns. The count in the list says so, and the
confirmation says it in words. Changing that to ON DELETE SET NULL, so the
work stays and loses its author, means rebuilding the tasks table and its
FTS triggers: worth doing, not worth doing quietly while adding a screen.
It is written down in the store instead.
Pending invites are listed and can be withdrawn. Only the token's hash is
stored, so a link sent to the wrong address cannot be looked up and
resent — withdrawing it is the only way to stop it before it expires.
Six API tests for the guards and the counts, and two smoke tests: the
whole loop from invite to a second browser creating the account and
appearing in the list as somebody who is not an administrator, and the
absence of buttons on your own row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`last_seen_at` has been written on every request since sessions existed —
throttled to once a minute, deliberately, so a list could one day say
"denne enhed, lige nu" — and nothing has ever read it. The store even had
ListSessions. What was missing was two routes and a panel.
GET /auth/sessions lists them, most recently used first, with the current
one marked so nobody has to work out which row is the browser they are
reading it in. DELETE /auth/sessions/{id} ends one. Ending the current
session is allowed and is simply logging out: refusing it would be a rule
with no reason behind it, and somebody clearing every device wants this
one gone too.
Both are behind requireSession rather than the ambient auth, the same as
the API tokens. A leaked token that could list its owner's devices and end
their sessions turns a theft into a lockout.
The delete is scoped in the statement rather than checked and then run.
An id read in one step and deleted in the next is a window, and the whole
reason somebody is on this screen is that they think another person is in
their account.
The device names are a handful of substring tests, not a parsing library.
The question this answers is "is one of these me?", and for that a wrong
guess between two Chromium forks costs nothing while a dependency that has
to be kept current costs something every year. Order is the whole
correctness of it: an iPhone's user agent says Mac OS X, Edge says Chrome,
and Chrome says Safari. The raw string is sent alongside and sits in the
row's title, so anything the summary gets wrong is still visible — and
something that is not a browser at all shows its own name rather than
"Ukendt", because a CalDAV client is a real session.
Times are relative. The question is "was that me, just now?", and an
absolute timestamp makes the reader do the subtraction; past a week it
switches to a date, because "for 23 dage siden" is a number nobody can
place.
The smoke test signs in a second time in a second browser context, ends
that session from the first, and checks the other browser is really signed
out — and that this one is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`assignee_id` has been on tasks since sharing was built and delegating has
been possible from the drawer since yesterday. What was missing was
anywhere to see it: work handed to somebody else left your lists and
appeared in nothing.
GET /delegated returns everything assigned to somebody who is not you, in
the projects you can see, grouped by the person. Grouped on the server
rather than flat, because the question is about people — "what is Anders
sitting on" — and a client cannot group it itself: it would need the name
behind an assignee id from a project it does not happen to have open, and
there is no way to ask for that. The names come back with the tasks, so
one request is one screen.
The filter is a field of its own rather than a sentinel in AssigneeID.
"What is on my plate" and "what have I handed over" are different
questions, and one string that means an id most of the time and a magic
word the rest is a field somebody eventually passes a real id to.
An assignee whose account is gone is named "Ukendt" rather than dropped.
Losing the row would make work disappear from the one view that exists to
stop work disappearing.
The sidebar entry only appears where delegating is possible at all. On an
instance with one person it could never have anything in it, and a
permanent empty entry is a question the sidebar keeps asking and answering
"no".
Building it turned up a bug in the split above it. "Projekter" and "Delt
med mig" were divided on `shared`, which only means the project has more
than one member — so inviting somebody to a project of your own moved it
under "Delt med mig", where it lost its place in the order, stopped being
draggable, and could not be filed under a group. It is divided on
ownership now, which is what the two headings have always said. Sharing
your own project is not the same event as somebody sharing theirs with
you.
The smoke test now follows the whole loop in one go: invite, accept in a
second browser, assign a task to the person who just joined, and find it
under their name — with the shared project still under "Projekter" on the
way past.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three the handoff named as next. They are one commit because they are
one mechanism: all three end in something being dragged, and a drop target
cannot tell what is coming without the module they now share.
Groups are foldable headings over the projects in the sidebar. A table
rather than a `group_name` column on the project, because a group has to
be renamable in one write, has to survive being emptied, and has to
remember whether it is folded — none of which a string repeated on every
project row can do.
Folded state lives on the row, not in localStorage. The sidebar's *width*
is a property of the screen you are sitting at, which is why that one is
per browser; a folded group is a statement about the group — "this is the
part of my work I am not in right now" — and it is true on the laptop and
on the desktop. There is nobody else's answer to disagree with either: a
group belongs to exactly one person.
A shared project cannot be filed. `group_id` is a column on the project
row, so a member grouping somebody else's project would move it in the
owner's sidebar too — the same reason `sort_order` is owner-scoped. The
listing reports the column only to the owner, so a viewer is never told
where somebody else keeps it.
Deleting a group takes the heading and leaves the projects, and the
sidebar decides where a project renders by whether its group still
exists rather than by the id alone. A group deleted in another tab
arrives as one event and not one per project, so for a moment the
projects still name a heading that is no longer drawn — filing by the raw
id would drop them out of the sidebar entirely.
Kommende gains the month grid the project pages already had, behind a
Liste/Kalender toggle. The two views ask for different things — seven days
from today against six whole weeks around an arbitrary month — so each
loads its own, and the grid reports which dates it is showing rather than
having the page guess. Which view is remembered in localStorage: a month
of seven columns needs room, and the answer on a phone is not the answer
on a wide monitor.
And a task can now be dropped on a project in the sidebar, on a day in
Kommende, or on a cell in any month grid. Onto a project it is a *move*,
not a field: `sort_order` belongs to the project, so it has to find a
place among that project's tasks and leave its old section behind.
Each drag now carries its own MIME type. `getData` is deliberately
unreadable while a drag is in the air — the browser hands the payload over
on drop and not before, so a page cannot read what is being dragged across
it — and `dataTransfer.types` is all a target has to decide with. Deciding
is the point: calling preventDefault on a drag you cannot accept is what
turns "no" into a silent, wrong drop. The sidebar takes a project and a
task on the same row and has to tell them apart.
Two things fell out of building it. The month grid has always had one cell
narrower than the other six: `.today` styled the "I dag" button in the
header, and the day cell for today wears the same class, so `margin-left:
auto` was shrinking a grid item to its content. It is scoped to the header
now. And a project page listed whatever was in the store rather than what
belonged to that project, which did not matter until a task could leave a
project while you were looking at it.
Covered by eight new API tests and two new smoke tests, and the smoke tests
drive the drags for real — the type check they exercise exists only while
a drag is in flight, so nothing else can reach it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The socket carried task events and nothing else, so a project or a label
created anywhere — another tab, another device, Claude through the
connector — did not appear until a reload. Tasks updated live and the
sidebar did not, which reads as the sidebar being broken rather than as a
gap in what is published.
Projects and labels go to the *user* rather than to a project. A project
that has just been created has no subscribers, because nobody was
watching a thing that did not exist a moment ago.
Labels are announced as "something changed" and re-read, rather than sent
as a payload. The sidebar wants each label's task count, which no single
event carries and which moves whenever a task gains or loses a label —
including through quick add, where "@regnskab" invents the label inside a
task write and no label event is sent at all. Comparing the label lists
on a task change is what catches that, without re-reading on every ticked
checkbox.
And projects now go through an upsert, because the same row arrives twice
— once as the response to the request that created it, once over the
socket. Appending both puts two ids into a keyed {#each}, which throws
and stops the sidebar rendering. The tasks already had that guard; adding
the project event without it reintroduced the identical bug, and the
smoke test caught it on the first run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Search covered task text only, so "regnskab" found nothing with the task
labelled @regnskab sitting in front of you — which reads as the search
box having lost it.
A label is often the only thing you remember. "That thing about the
accounts" is a search for @regnskab, not for any word in the title.
The label side is a plain lowercase match rather than a second FTS index.
Labels are short, and their names are typed by the same person now
searching for them, so there is no spelling to be generous about — the
diacritic folding that earns its keep on task text does not earn a second
index here.
The join sits inside the WHERE the visibility rules already build, so
somebody else's label cannot pull their task into your results. There is
a test for that, because it is the kind of thing an OR is good at
breaking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things you could not do to a task you had open, and one that had
never worked at all.
**Project, recurrence and assignee** are now fields in the drawer. A task
created in the wrong place could only be fixed by dragging it, a repeat
could only be set by typing it into the title when the task was created,
and delegation had no interface whatsoever despite assignee_id being in
the API from the start. The assignee only appears on a project with
somebody else in it, because on your own there is nobody to choose.
**The title is read the way quick add reads it.** Typing "i morgen kl 14
p1" into a title used to leave it as those words, so where you typed a
sentence decided what it meant. The parse runs on the server — the
grammar is Danish and English and lives there — and applies only when it
consumed something. What it took is said out loud, and the fields on
screen are updated to match: the drawer otherwise seeds its inputs once
per task so a save cannot overwrite what you are typing, and that rule
has to bend exactly here, where the save changed the very fields in front
of you.
**And the member list has never worked.** The share panel, and now the
assignee field, ask for it. Every call errored: the ORDER BY named an
expression, and SQLite only accepts result-set columns across a UNION, so
it failed at query time with "1st ORDER BY term does not match any column
in the result set". Found by the smoke test that opens a task, which is
the second real bug that suite has caught by watching for failed requests
rather than only for what renders.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reports from somebody driving the app through the MCP connector,
all the same shape: an argument accepted, an OK returned, and nothing
done.
**project_id on an update never moved the task.** Both the REST handler
and the MCP tool read it, both checked the caller's rights on the
destination — which reads as "understood" — and then neither passed it
on, because store.TaskUpdate has no such field: a project change is a
move, since sort_order is per project. So the task stayed where it was
and the answer said OK. Moving a task between projects was impossible
except by creating it again and closing the old one, which is what the
person ended up doing. Both paths now go through one helper, so they
cannot drift apart again.
**An unknown #project vanished without a word.** The parser reads
"#3Dekoration" correctly; the lookup then fails, the task goes to the
Inbox, and the name is gone from the title with nothing anywhere saying
why. Still not an error — a thought should not be thrown away over a typo
— but quick-add now returns unknown_project, and the MCP tool returns a
note written for a model to relay. The old behaviour was kind in the web
interface, where you see the row appear in the wrong place and fix it. In
a chat nobody sees the row.
**Sub-tasks were unreachable over MCP.** create_task had no parent_id,
though the REST API has had it all along, so importing a parent with
seven children produced eight unrelated tasks with the parent's name
prefixed onto each. A sub-task joins its parent's project — the two
cannot disagree — and a second level is refused rather than silently
flattened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Projects have carried sort_order since the first migration and nothing
ever set it, so the sidebar was ordered by creation date for good.
POST /projects/reorder takes the whole list rather than one move. A
person has a handful of projects, so sending the order you want is
simpler to reason about than midpoints between neighbours — and it cannot
run out of precision, which is the failure the task ordering has to
respace a section to recover from. It also cannot land half-applied the
way a sequence of individual moves can.
Projects you do not own are skipped rather than refused. sort_order is a
column on the project, not a preference per viewer, so a shared project
sits where its owner put it and a member rearranging their own sidebar
does not rearrange somebody else's. The sidebar only makes your own
projects draggable, so that rule never shows up as a row that springs
back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding the connector to Claude failed with "Couldn't register with
Verdande's sign-in service", on a server that has no sign-in service and
needs none.
Claude probes /.well-known/oauth-authorization-server before connecting to
an MCP server. The SPA fallback answered 200 with a page of HTML — which
reads as "yes, there is an authorization server here" — so it went on to
dynamic client registration and failed there instead of concluding the
obvious thing, which is that this server does not use OAuth and the key in
the URL is the whole credential.
/.well-known/ is reserved by RFC 8615 and is never a route in this app. A
404 is the true answer, and it is also the useful one: the client stops
asking and uses the URL as given.
This is the third time the catch-all has caused a confusing failure by
saying 200 to something that does not exist — /mcp itself was the second.
The fallback should hand back the shell for routes the app might have, not
for every path on the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A project could be created and shared and never renamed or deleted. Both
have worked through the API since the beginning; there was simply nothing
to click.
The heading is now the field — a heading that happens to be editable
rather than a form that looks like one — and saves on blur, with Escape
putting the old name back. Owner only, and the Inbox keeps its plain
heading because it cannot be either renamed away or deleted.
Deleting needed more than a button. It is soft, with thirty days to
change your mind, and restoring has been possible the whole time — from
an id, which is exactly what you no longer have once the project has gone
from the screen. A recovery window nobody can reach is not one. So
GET /trash/projects lists what you have deleted, with how many tasks went
with it and how long is left, and Settings → Data has the button.
Owned, not shared: a member of a project somebody else deleted has no
business bringing it back, and there is a test that says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"I have updated and restarted, but I cannot tell whether it worked" is
not a question the panel could answer. The version was on the startup
line and in the settings page, and nowhere you can reach from outside.
/healthz carries it now. That is the one endpoint answering without a
session, which makes it the only way to ask the question from a terminal
or from the panel itself. The "listening" line repeats it too, because
that is the line the Rune's done_regex matches and therefore the one
sitting at the bottom of the console after a deploy.
The update notice could also never have worked. It reads GitHub's
releases/latest, and this repository had only tags — a tag is not a
release, and that endpoint answers 404 for a repository without any. The
release workflow creates one now, after the image is pushed so a release
never announces a version that failed to build.
VERDANDE_UPDATE_CHECK is a Rune variable, so it can be switched on
without editing the container. Still off by default: it is an outbound
request, and that is a choice to make rather than a default to inherit.
Rune version 1 -> 2, so the panel offers the update.
Note that the check still cannot see anything while the repository is
private — releases/latest is 404 anonymously, exactly as the GHCR package
was. Making the repo public, or teaching the check to carry a token, is a
separate decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs with one shape: the interface confidently told you something
that was not so.
Connecting Gmail without a registered OAuth client said "Det er der
allerede." Thirteen different situations answered a plain `conflict`, and
the frontend renders that as "that already exists" — which for four of
them is not vague but wrong. An unconfigured Gmail is not a Gmail that is
already connected; a second factor that is off is not one that is on; the
Inbox is not a project that already exists somewhere else. Those four
have their own codes now, and their own sentences.
The mail-to-task address came out as todo+…@localhost. VERDANDE_SMTP_FROM
defaults to verdande@localhost and the domain was taken from it
unconditionally, so an instance with no mail server handed out an address
that looks real, can never receive anything, and gives no hint which of
the two it is. The default no longer wins over the public hostname, and
the response now carries whether a mail server exists at all — because
even with the right domain, nothing routes to it until one does.
errors.go has always claimed the code list is kept in one place "so the
frontend's mapping can be checked against it". Nothing checked it, and
the mapping was missing bad_request. Now a test reads both files and
fails on a code with no message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
verdande could not be added to Claude at all. The documented setup —
POST /api/v1/mcp with an Authorization header — cannot be configured from
Claude's custom-connector dialog, which asks for a name, a URL and OAuth
client credentials and has no field for a bearer token. The instructions
described something the interface does not offer.
Worse, the obvious guess failed silently in the most misleading way
available: /mcp was not a route, so the SPA fallback answered 200 with a
page of HTML. The connector reported a successful connection and then
failed to parse the response, which reads as a broken client rather than
a missing endpoint.
So /mcp exists now and takes its credential in the query string, exactly
as the calendar feed does and for the same reason: a client that cannot
be told to send a header means the address is the credential. The
request logger records paths without their query strings, so the key does
not reach the log.
It is deliberately outside /api/v1 and accepts the key and nothing else.
Reading a session cookie here would make it a state-changing POST outside
the CSRF check — a cross-site request that acts as whoever is signed in —
and there is a test that fails if that ever becomes true.
The API path is unchanged for anything that can send a header. The tokens
page now shows the connector address next to the token it belongs to,
since that is the one moment the token exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The brief asked for one. docs/api.md is complete and hand-written, and
there was nothing a tool could read.
Written by hand rather than generated from annotations, which would have
meant every handler carrying a second copy of itself in a comment. The
risk with a hand-written spec is that it goes stale, so it does not
depend on anybody remembering: openapi_test.go walks the chi router and
fails both on a route nothing describes and on a description with no
route behind it. CI separately checks it is a valid OpenAPI document.
The test reads the file with a forty-line scanner rather than pulling in
a YAML library. It needs two things — which paths are listed and which
methods each has — and both sit at fixed indentation in a document the
test also owns, which is worth more than a dependency in a project whose
whole shape is one static binary.
The CalDAV routes are excluded deliberately: PROPFIND and REPORT are
outside what OpenAPI can describe, and docs/caldav.md covers them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/api.md has told people to make a token under Settings since it was
written. The store has held CreateAPIToken, ListAPITokens and
DeleteAPIToken since the first migration. There were no routes: nothing
between the two, and no way to make one.
GET, POST and DELETE under /tokens, all behind a new requireSession
middleware. A bearer token is refused there with 403 even though it is
accepted everywhere else — otherwise a leaked token is permanent, because
whoever has it issues a second one and revoking the first changes
nothing. The plaintext comes back from POST and never again; only the
hash is stored, so the interface has to say so at that moment rather than
offering a reveal that cannot work.
PATCH /auth/me alongside it. Name, timezone and language could not be
changed anywhere — not through the API and not through the interface —
and an account created from an invite inherits a default timezone. That
is not cosmetic: every date in the app is resolved in it, so somebody in
another country had every due date land an hour out with no way to say
so. The zone is validated against the running system's database rather
than a list kept here, which is the same database the dates are resolved
with. The email address stays read-only: it identifies the account and is
what invites were sent to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
authenticate() cannot tell "no such session" from "the session lookup
failed" on its own — both arrive as an error. Answering 401 for both
meant a disk or file-permission problem presented as every session
ending at once: the frontend clears its state and shows the sign-in
screen to everybody, and the logs record a wave of failed logins rather
than the fault.
Now only a missing or unknown credential is 401. Anything else is a 500
and is logged as what it is.
Found while writing the smoke tests, where a deleted database file
produced SQLITE_CANTOPEN on the connections that had not been opened
yet — so most requests succeeded and a few reported the user as signed
out, which reads exactly like a flaky auth bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two people saving at the same moment got a 500 after two milliseconds.
The DSN has set busy_timeout since the first commit, and it covered none
of the contention it looks like it covers. With a plain BEGIN a write
transaction starts as a reader and asks for the write lock at its first
UPDATE — and SQLite refuses that upgrade with SQLITE_BUSY immediately,
without applying busy_timeout to it. Backing off cannot help: the
transaction is already holding a read snapshot the other writer is
invalidating.
_txlock=immediate takes the lock at BEGIN instead, where the timeout does
apply and the second writer waits its turn. The driver leaves read-only
transactions deferred, so readers still run concurrently, and every one
of the twenty callers of db.Tx is a write.
Found by opening a task and saving a description, a sub-task and a
comment at once — three writes, one of which came back a 500. The test
fails without the flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gmail now connects. Authorisation-code flow with PKCE, the verifier kept
server-side rather than in a cookie — the callback is a top-level
navigation from Google, and a SameSite=Lax cookie is the kind of thing
that works until a browser tightens its defaults. State is checked, the
attempt expires after ten minutes, and the spent verifier is deleted
rather than left at rest.
Scope is gmail.readonly and the sync is one-way on purpose. Unstarring a
message does not delete the task; completing the task does not touch the
mailbox. Two-way would mean deciding what happens when both sides
changed, and the only honest answer is a sync model far larger than the
feature. The query carries newer_than:30d so connecting an account with
a decade of starred mail does not create a decade of tasks.
The update check is off unless the operator turns it on, asks GitHub at
most every six hours, and sends nothing about the instance — no
identifier, no version, no counts. A development build never reports an
update: somebody running their own tree is ahead of every release, and
telling them otherwise trains them to ignore the notice. Only
administrators see it, because only they can act on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MCP server speaks JSON-RPC over Streamable HTTP with the six tools
the brief names, authenticated by a personal API token — so a connector
reaches exactly what its owner reaches and there is no second identity
to reason about. A failed tool call returns a tool error, not a protocol
error: a model can recover from the first and can do nothing with the
second.
CalDAV serves VTODO, two-way, so Apple Reminders and Thunderbird can
read, create and complete tasks. Clients authenticate with Basic using
an API token as the password, never the account password — a client
stores what it is given, and what it is given should not be able to sign
in to the app. Completed tasks stay in the collection: a client that
stops seeing one treats it as deleted and drops its own record of having
done it.
The AI layer is one interface with adapters for Anthropic, OpenAI,
Google and any OpenAI-compatible endpoint. That last one is why the
abstraction exists: an integration that only spoke to a hosted API would
be useless to somebody running their own models. Every feature degrades
to "off" when nothing is configured, and the stored API key is never
returned to the settings page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments and attachments close out phase 2. Files are stored
content-addressed by hash, so the path never contains anything the
uploader chose — a filename is attacker-controlled text, and building a
path from it is how "../" becomes a write outside the data directory.
Everything downloads as application/octet-stream: an uploaded SVG served
inline would run its own script on this origin with the session cookie
attached.
Web Push is written directly against RFC 8030/8291/8292 rather than
pulled in — it is three specs' worth of well-defined crypto and about
two hundred lines, and the alternatives carry a JWT and crypto stack
each larger than the file.
Phase 4 imports Todoist's CSV and exports it back. Todoist numbers
priorities the opposite way to how it displays them — its 4 is P1 — so
the two are converted rather than copied; getting it backwards silently
inverts the urgency of every imported task. Nesting is positional, so
the reader keeps an indent stack and the writer never sorts.
The round trip is tested as the brief asks: a CSV in, out, and back
produces an identical project, and two exports of one project are
byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board is one column per section with native drag-and-drop, which
works with a trackpad, a mouse and a screen reader without any of them
being reimplemented. Dropping is optimistic: a card that snaps back for
200ms reads as a failed drop.
Weeks in the calendar start on Monday. That is ISO 8601 and what every
Danish calendar does; a Sunday-first grid makes a Dane misread the month
at a glance. On a phone the chips give way to coloured bars — at that
width the month's shape is the useful part, not the titles.
Label pages run the filter language rather than a bespoke endpoint, so a
label page and a saved filter can never disagree about what a label
selects.
fix(recurrence): "hver mandag", not "hver uge mandag"
A weekly rule that names its days does not also need the word for the
interval. That is how the phrase is said, and it is what the parser
accepts back — the round trip through Describe now holds for weekday
rules too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reminders are absolute or relative ("10 minutes before"). A relative one
is resolved against the task in SQL, so moving a task moves its reminders
too. They are marked sent before delivery, not after: a reminder that
goes out unrecorded repeats every minute forever, which is worse than
one recorded that fails to send.
The calendar feed is a per-user token in the path, because Apple Calendar
and Google fetch with no credentials and no way to prompt for any. Tasks
go out as VTODO — the same component CalDAV will serve — and timed ones
also as VEVENT, since Google ignores VTODO entirely.
Templates store relative day offsets rather than dates, which is what
makes them reusable: an onboarding checklist is "day 1, day 3, day 14",
not three dates in March. Building a project from one is a single
transaction, so a failure cannot leave half a checklist.
Backups use SQLite's VACUUM INTO, not a file copy — a copy of a live
WAL database can miss recent writes or catch a page mid-update, and the
result looks like a database right up until it is needed. Rotation keeps
fourteen by count, not by age: a container that was off for a month must
not come back and delete every backup it has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"today & p1", "#Firma & @regnskab", "overdue | (today & assigned to: me)".
The grammar is Todoist's, so the import path from it does not require
relearning anything, and both languages' keywords are accepted.
It compiles to parameterised SQL rather than filtering in Go: one
indexed query instead of ten thousand rows crossing the boundary. No
typed text is ever concatenated into the statement — this is a language
people write by hand, which makes it exactly where string-building would
become an injection.
An expression is compiled before it is saved. A filter that cannot run
is worse than none: it is discovered later, as a list that is empty for
a reason nobody can see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"vand planterne hver mandag" becomes FREQ=WEEKLY;BYDAY=MO. The stored
form is RFC 5545, not a private format, so the same field will drive the
CalDAV server and the ICS feed without a translation layer.
Ticking off a repeating task advances it instead of closing it, keeping
the same row — so its sub-tasks and comments stay attached. A weekly
review is one thing that recurs, not fifty-two things. The clock time
survives the move, and the completion is still written to the activity
log, because "what did I get done this week" has to include the chores
that repeat.
Recurrence is parsed before dates, and that ordering is load-bearing:
"hver mandag" contains a weekday, so a date parser reading it first
would claim Monday as a one-off and strand "hver" in the title. It is
also resolved before the time-only case, or "hver mandag kl 9" typed
after nine would land tomorrow morning instead of on Monday.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runs in UTC and the API resolves due dates in the user's timezone,
which defaults to Europe/Copenhagen. For two hours out of every
twenty-four those disagree, so tests written against time.Now() passed
all day locally and failed on a late-evening push.
The application was right; the tests were asking it the wrong question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interface for phase 1: sign-in and first-run setup, Today, Upcoming,
project views, sharing, Cmd+K search and the keyboard shortcuts.
Quick add highlights what the parser understood underneath the text as
you type — a mirrored layer behind a transparent input, sliced by byte
offset because the server counts bytes and JavaScript counts UTF-16
units, which disagree on exactly the Danish letters this app is full of.
Every local action is optimistic and reconciled afterwards: ticking a
task off strikes it through and animates it out before the request
leaves, and rolls back with a message if the server disagrees.
The Content-Security-Policy is computed at startup by hashing the inline
scripts in the built index.html. Both of them change on every frontend
build, so a hard-coded hash would go stale silently and show up only as
a blank page in production. The alternative, 'unsafe-inline', would drop
the one protection that makes an injected script in a task title inert.
Today is derived from the store by date rather than from the ids that
came back with the first request — filtering against a captured list
meant a task added through quick add never appeared until a reload.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the MVP surface: project and section CRUD, task CRUD with
sub-trees, drag-and-drop ordering, the quick-add endpoint over the
natural-language parser, Today and Upcoming, search, sharing with roles,
an activity log and a WebSocket for live updates.
Permission checks are route middleware rather than a first line in each
handler, so an endpoint is guarded by where it sits instead of by the
author remembering. Anything the caller may not see answers 404, never
403 — a 403 confirms the thing exists. Visibility is enforced inside the
list query, so another user's project cannot be reached by filtering or
searching at it.
Ordering is a float, so a drop writes one row instead of renumbering the
list. When repeated drops into one gap exhaust the precision, the
section is spread out again and the move retried — in a separate
transaction, since the attempt that discovered the problem rolls back.
The connection pool is not 1: a single connection makes the pool a lock,
and a query run while an earlier result set is still open then waits for
a connection it is itself holding. The store now closes its rows before
querying again, and the pool leaves that mistake slow rather than fatal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sessions are stored as hashes, so a database dump contains nothing that
can be pasted into a browser. A login that has passed the password but
not the second factor is modelled as a pending session: it expires, is
revocable, and reaches exactly one endpoint.
Signup is invite-only and takes the email address from the invite rather
than the form, so a link cannot be redirected to somebody else. The
first admin is created by an endpoint that refuses once any account
exists, checked and inserted in one transaction because it is
necessarily unauthenticated.
Login failures and forgotten-password requests answer identically
whether or not the address has an account — otherwise the form is an
account-existence oracle. Password rules are a length floor and nothing
else: composition rules push people towards worse passwords.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The HTTP server serves /healthz, the API namespace and the PWA, with the
frontend embedded behind a build tag so a clean checkout compiles without
a Node toolchain. /healthz reads the database rather than just answering:
a process that cannot reach its own data is not healthy, and a 200 there
would keep a broken container in rotation.
The image is distroless and static — the SQLite driver is pure Go, so
CGO stays off. The Rune manifest uses keep_entrypoint because there is
no shell in the image to run a startup command with, and its log watchers
match the JSON log format the container actually emits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Password hashes are self-describing PHC strings, so the cost parameters
can be raised later without locking anyone out: NeedsRehash reports which
hashes to upgrade at the next login, when the plaintext is briefly at hand.
Session, invite, reset and API tokens are stored only as SHA-256 hashes;
the plaintext exists once, in the link or the reply. Unsalted is correct
here and only here — the input is 256 bits of randomness that has to be
found by value in a WHERE clause.
feat(store): project roles with viewer/editor/owner and explicit denial
Roles are strictly ordered so every check is "at least X". Access to a
task is access to its project: being the assignee or the author confers
nothing, or removing someone from a project would leave them holding
tasks inside it. Checks take a Querier so they can run in the same
transaction as the write they guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parses date, time, priority, project and labels out of one typed line,
e.g. "betal moms i morgen kl 10 p1 #Firma @regnskab".
Both languages are read at once rather than behind a locale switch,
because people mix them mid-sentence. Locale settles only one genuine
ambiguity: a bare hour after English "at".
Each stage reads a copy of the line with earlier matches blanked out,
so no two readings can claim the same characters — without it "kl 10.12"
is a time and, to the date parser, also the 10th of December. Matches
are returned as byte spans so the input box can highlight what it
understood as you type.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Initial data model for verdande: users, sessions, invites, projects,
sections, tasks, labels, reminders, comments, attachments, activity
and filters, plus personal API tokens.
Search indexes a generated `fold` column alongside the original text.
FTS5's remove_diacritics does not touch ø and æ — Unicode treats them
as letters rather than accented forms — so without it a Danish user
cannot find "grøn" by typing "gron". MatchExpr quotes every term so
free text containing FTS5 operators cannot reach the query planner as
syntax.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>