Skip to content

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.

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.

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.tasks is already the whole list in memory, and every other derivation on CadenceUiState is an in-memory index. tasksByTag is 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.

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. setTaskTags therefore 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 QuickAddParser takes 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. matchingHandle widens 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 @workout and @workshop present, @work would 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.

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.

  • Schema version 5. 4.sqm adds tagRow, taskRow.tagIds and syncStateRow.tagCursor; every one is a CREATE TABLE or an appended ADD 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.VERSION does not move. tags and tagIds are additive, and an older install must not refuse a whole file over keys it can ignore — the rule sections already 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.