ADR 0004 — Tags, and where membership lives
Status: accepted · Date: 2026-08-22 · Supersedes nothing. Extends ADR 0002, whose merge rule decides most of what follows.
Context
Section titled “Context”A project answers “where does this live”. It nests one level, a task has exactly one, and deleting one is a decision about the work inside it. That is the right shape for Home / Finance and the wrong shape for waiting on someone, takes five minutes, only at a computer — properties that cut across half a dozen projects at once and describe the task rather than file it.
#41 asked for the second thing. This ADR records the decisions, because the interesting one is invisible from the UI and expensive to reverse.
Decisions
Section titled “Decisions”1. A tag is identity only; membership is a column on the task
Section titled “1. A tag is identity only; membership is a column on the task”tagRow holds a tag’s name, colour and position. It does not hold which tasks wear it.
taskRow.tagIds does — every id the task carries, packed comma-separated into one TEXT column by
TagIdsCodec, exactly as RecurrenceCodec packs a rule.
The obvious alternative is a taskTagRow join table, one row per (task, tag) pair, synced like
every other table. It is the normalised answer and it is the wrong one here:
- A join row is a synced record, and every synced record is a tombstone forever. Deleting a
tag from a task cannot be a
DELETE— ADR 0002 decision 4 — so it becomes a tombstone that travels, is pushed, is pulled, and sits in both databases for 90 days. Labels are applied and removed far more often than tasks are created, so the table with the most churn in the system would be the one holding the least information. - It doubles the sync machinery for one feature. Cursors, a push list, a merge, a page loop, a
realtime binding, RLS, a stale-write trigger and a sweep are hand-written per table
(
CadenceSyncEngine). Tags need one set of those. A join table needs two. - Deleting a tag would rewrite every task that wore it. With the packed column it is one row; with a join table it is one row per labelled task, and a tag on two thousand tasks becomes two thousand rows on the next push, for a change nobody can see.
- Reading is free either way.
repository.tasksis already the whole list in memory, and every other derivation onCadenceUiStateis an in-memory index.tasksByTagis one more.
What it costs, stated plainly: two devices adding different tags to the same task while offline resolve last-writer-wins over the whole label set, and one of the two additions is lost. A join table would merge them. That is a real loss, and it is the same one the app already accepts for a concurrently edited title, note or due date — ADR 0002 chose last-writer-wins outright, and a single feature is not the place to introduce a second merge model.
The wire and the file are not packed: tasks.tag_ids is a Postgres uuid[] with a GIN index and
BackupTask.tagIds is a JSON array. The packed string is a storage detail, the way ADR 0002
decision 5 already separates the two.
2. Deleting a tag rewrites nothing, and the read side repairs the link
Section titled “2. Deleting a tag rewrites nothing, and the read side repairs the link”TagStore.tombstone writes one row. Every task that named the tag keeps the id.
CadenceUiState.tagsOf drops an id no live tag answers to, which is the same “links are repaired
rather than trusted” rule BackupCodec follows for a record its file lacks. Three consequences,
all wanted:
- A delete is O(1) on both devices and on the wire.
- Reviving a tag puts it back on exactly the tasks that had it — from a backup file, or from the other device pushing a version newer than the tombstone. Nothing had to remember the membership, because nothing ever forgot it.
- A task can name a tag that has not arrived yet, which is the ordinary case during a pull: pages
arrive in whatever order, and a repository that pruned unknown ids on write would erase a label
a moment before its tag landed.
setTaskTagstherefore keeps them.
The server behaves the same way, and the migration test asserts it: sweeping a tag tombstone leaves
tasks.tag_ids untouched.
3. Names are unique case-insensitively, and that is a validation, not a merge
Section titled “3. Names are unique case-insensitively, and that is a validation, not a merge”@home in the quick-add line has to mean one tag. CadenceRepository.upsertTag refuses a name
another live tag already holds, ignoring case.
It is a validation on this device and nothing more. Sync can still land two tags called Home —
two devices creating one offline is the obvious way — and when it does, both are shown and both are
usable. Reconciling them would mean rewriting the tasks that name the loser, which is exactly the
O(n) write decision 2 exists to avoid, for a collision that is cosmetic.
4. @handle, because # is already the project
Section titled “4. @handle, because # is already the project”Todoist’s split, and for the same reason: #Home has meant “project” in this app’s quick-add line
since it existed, so tags take @. Tag.handle is the name with its spaces removed, so a tag
called Deep Work is reachable as @deepwork without forcing tag names to be single words.
Two details that are not obvious:
- Tags are the one token kind that repeats. Every other pass in
QuickAddParsertakes the first match; the tag pass takes all of them, because a task is filed once and labelled as often as it likes. - A handle that matches nothing creates a tag, unlike
#project, which only ever selects. A project is a place in the sidebar and creating one by typo is a mess to clean up; a stray tag is one row in a flat list and one tap to remove. The chip in the sheet shows it as new before the task is filed. - Because it creates, the prefix pass has to be strict.
matchingHandlewidens from exact name to spaceless handle to prefix, and the prefix pass answers only when exactly one tag matches. Guessing would be worse than not matching in both directions at once: with@workoutand@workshoppresent,@workwould silently label the task with whichever sorts first, and a tag genuinely called work could never be created from the quick-add line again.
The @ must start a word, checked against the preceding character in Kotlin rather than with a
\b in the pattern — ICU and java.util.regex disagree about word boundaries, and the unit tests
run on the second while the device runs on the first (the rule QuickAddPatterns already states).
Without the check, “reply to mail@example.com” grows an @example tag and loses the address out of
the title.
5. Tags are not a fifth destination
Section titled “5. Tags are not a fifth destination”They hang off the Projects screen, and the desktop lists them under the project tree in the sidebar. Tapping a tag opens a flat, cross-project list; that route is a detail route on the desktop, so on a wide window it fills the second pane like a project does.
The four top-level lists are the work. Tags are a way of finding work, so they sit with the other organising machinery rather than taking a fifth of the bottom bar from Today, Upcoming, Inbox and Projects.
6. The tag list is banded like nothing else, on purpose
Section titled “6. The tag list is banded like nothing else, on purpose”TaskView.Tag returns one band. A project’s list inherits section headings or an overdue split; a
tag cuts across projects, so there is no structure to borrow, and every row keeps its project label
because “which project is this in” is the question the list raises.
It applies showCompleted (unlike Search, which always reaches history) and drops superseded
recurring occurrences (like the Inbox, and unlike Today): a tag list is a place you work from,
and a daily @errand would otherwise stack up a struck-through copy per day.
7. A label on a parent says nothing about its steps
Section titled “7. A label on a parent says nothing about its steps”Moving a task to another project moves its subtasks; filing it under a section moves them too.
Tagging it does not. @errand on “Move house” describes the whole errand, and silently stamping it
on five subtasks would make the tag’s list five times longer than the work in it.
A recurring task does hand its labels to the next occurrence, along with its checklist and its attachments — those are all the method of doing the work, and the work repeats.
8. A tag is dropped onto, never into — and dragging one only reorders
Section titled “8. A tag is dropped onto, never into — and dragging one only reorders”DropTarget.IntoTag puts a label on a dropped task and keeps the ones it has. Add-only, which is
why CadenceViewModel.applyTag sits beside toggleTag: the dragged row is a snapshot taken when
the drag began, so a toggle would remove a label from a task that gained it meanwhile — and a
gesture that adds or removes depending on state the pointer cannot see is not one anybody can aim.
resolveDrop rejects a drop onto a label the task already wears, so the row never lights up for a
drop that would change nothing.
Dragging a tag resolves to ReorderTags and nothing else. There is no “drop a tag on a tag”:
a tag holds no tasks (decision 1), so the only meaning available would be merge these two, which
is the O(n) rewrite of every labelled task that decision 1 exists to avoid.
The Tags screen is where the order is written — sortOrder was a column nothing could change
until this landed — and it offers two ways, because the drag kernel needs a host. DragAndDropHost
wraps the desktop window and nothing on Android, so a drag source on a phone would be a gesture
that eats the list’s scroll and then does nothing; the rows there are reordered from the row menu
(Move up / Move down), which is the same write. reorderable is the one platform flag the
screen takes, in the shape SyncControls already uses for the same kind of difference.
9. Search reads labels, and @ narrows to them
Section titled “9. Search reads labels, and @ narrows to them”searchList matches title, notes and labels; a query starting with @ matches labels only. This
is how a phone reaches a tag by typing at all — the thing that answers @ on the desktop is the
command palette, which Android does not have. Matching is contains, looser than
matchingHandle’s ladder, because a search that returns a few rows too many costs a glance while a
wrongly matched label costs a mislabelled task.
Consequences
Section titled “Consequences”- Schema version 5.
4.sqmaddstagRow,taskRow.tagIdsandsyncStateRow.tagCursor; every one is aCREATE TABLEor an appendedADD COLUMN, so nothing is rebuilt. - A fourth synced table,
supabase/migrations/20260822120000_tags.sql, with the sweep extended to collect its tombstones in the same file — unlike sections, whose table shipped before the sweep knew about it and needed a second migration to catch up. BackupCodec.VERSIONdoes not move.tagsandtagIdsare additive, and an older install must not refuse a whole file over keys it can ignore — the rulesectionsalready set.- Tag colours are
PROJECT_COLORS, shared rather than duplicated. A tag is told apart from a project by the@on its chip, never by which six colours it may wear — colour alone carries no meaning anywhere in this app.