docs: plan the tag cloud and task relationships
Assisted-by: Claude Opus 5 via Claude Code
change xzkqzsvrxwkplxzqxzkmztouyprywrsl
commit 47ad477a3054cc635b8645f3ec07f53ba0ef2ce8
author Alpha Chen <alpha@kejadlen.dev>
date
parent mttlqwlv
diff --git a/docs/plans/2026-09-10-tag-cloud.md b/docs/plans/2026-09-10-tag-cloud.md
new file mode 100644
index 0000000..66a9d97
--- /dev/null
+++ b/docs/plans/2026-09-10-tag-cloud.md
@@ -0,0 +1,191 @@
+# Tag cloud and task relationships
+
+**Status:** Proposal. Not scheduled; written down to think through the shape of the change before deciding whether to do it.
+
+This document proposes two changes to Ranger's data model:
+
+- Replace backlogs with a single pool of tasks organized by `key:value` tags. A backlog becomes a tag such as `project:ranger`, and the default backlog becomes a default filter.
+- Replace stored positions with a graph of task relationships. Blockers and priority are both edges, task order is derived from them, and the graph must be acyclic. Subtasks are a separate tree.
+
+The two changes are independent. Neither needs the other, and shipping them together would multiply the risk in `src/ops/task.rs`, which holds both the backlog scoping and the ordering logic. Treat them as separate projects.
+
+Out of scope: multi-user features, saved views, and deriving task state from relationships (see [Relationships don't change state](#relationships-dont-change-state)).
+
+## Background
+
+Today every task belongs to exactly one backlog. `tasks.backlog_id` and `tasks.position` live on the task row, and `position` is a lexicographic fractional index scoped to the backlog, maintained by `src/position.rs`. Tags are free-form labels in a `tags` table joined through `task_tags`.
+
+Ranger has tried relationships before. The initial schema had `tasks.parent_id` and a `blockers` table. Migration `005_drop_tags_blockers.sql` dropped blockers, `008_drop_parent_id.sql` dropped `parent_id`, and `009_task_edges.sql` added a `task_edges` table with the edge kinds `blocks` and `before` that `011_drop_task_edges.sql` removed. The design below is close to that dropped table, so the reason it didn't stick is the first thing to establish.
+
+## Part 1: tags replace backlogs
+
+### Tags store their parsed key and value
+
+Store the parse alongside the name:
+
+```sql
+CREATE TABLE tags (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    name TEXT NOT NULL UNIQUE,
+    key TEXT,   -- NULL for bare tags such as `bug`
+    value TEXT
+);
+```
+
+Split on the first colon when a tag is created. Queries such as "every `project:` value" and shell completion for tag values then read columns instead of repeating `substr`/`instr` in every call site.
+
+The schema doesn't limit a task to one value per key. A task may carry both `project:ranger` and `project:dotfiles`; that flexibility is the main reason to drop backlogs. Per-key exclusivity could be added later as a policy, but it isn't part of this proposal.
+
+### Filters are a small term language
+
+A filter is a space-separated conjunction of terms:
+
+```
+project:ranger -someday state:ready
+```
+
+- A bare term (`bug`) or a `key:value` term matches tasks with that tag.
+- A leading `-` negates a term.
+- `state:` matches task state rather than a tag.
+- `blocked:` matches whether a task has unfinished blockers (see [Part 2](#part-2-task-relationships)).
+
+There is no OR and no grouping. If alternation becomes necessary, same-key alternation (`project:ranger|dotfiles`) covers most cases without a real parser. The existing `--state` and `--tag` flags stay as shorthand that appends terms.
+
+`RANGER_DEFAULT_FILTER` replaces `RANGER_DEFAULT_BACKLOG`. Ranger has one user, so the old variable can be dropped rather than deprecated. An empty or unset filter matches every task.
+
+`task create` applies the default filter's positive tag terms to the new task, so creating a task under `project:ranger` tags it `project:ranger`. The creation doesn't apply negated terms, `state:` or `blocked:` terms, or alternation terms, which name more than one tag.
+
+### Key lookup ignores the default filter
+
+Today `RANGER_DEFAULT_BACKLOG` also scopes task key lookup, so a key that exists in another backlog fails with `no key matching prefix`. The `ranger` skill documents this as a gotcha. The default filter should affect listing only. Keys are globally unique, and shortest-prefix computation already runs over all keys through `ops::task::all_keys`, so resolving keys globally removes the gotcha without new ambiguity.
+
+### Migration
+
+The data moves mechanically:
+
+1. Create one `project:<name>` tag per backlog.
+2. Attach each task to the tag for its backlog.
+3. Recreate `tasks` without `backlog_id`.
+
+Recreating `tasks` must run inside `PRAGMA foreign_keys = OFF` / `ON`, as migrations 008, 010, and 013 do, or the `ON DELETE CASCADE` on `task_tags` deletes every tag association.
+
+Positions survive this part unchanged. Part 2 replaces them, and the two migrations should not be combined — converting positions to edges while `backlog_id` is still present keeps each chain scoped to a backlog, which is what makes the conversion meaningful.
+
+## Part 2: order comes from edges
+
+### Why positions go away
+
+Ranger's stored `position` asserts a total order: every task is comparable to every other task, including tasks in unrelated projects that were never weighed against each other. Most of that order is invented. What actually gets decided is relative — "this before that" — so the model should store the comparisons that were made and nothing else.
+
+The consequence is that task order becomes a partial order, and any list has to linearize it. That linearization is arbitrary wherever no comparison exists, so the tiebreak is part of the design rather than an implementation detail.
+
+### One table for order-bearing edges
+
+Blockers and priority differ in meaning but not in shape. "A blocks B" says B cannot start until A is done; "A before B" says B matters less. Both constrain order in the same direction, and the linearization consumes their union, so they share a table:
+
+```sql
+CREATE TABLE task_edges (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    from_task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+    to_task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+    kind TEXT NOT NULL CHECK (kind IN ('blocks', 'before')),
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+    UNIQUE (from_task_id, to_task_id, kind)
+);
+```
+
+Subtasks stay out of this table. A parent-child relationship is nesting, not ordering: children render indented under their parent rather than at some position in a flat list. Subtasks get a nullable `parent_id` column on `tasks`, which enforces the one-parent rule in the schema for free.
+
+### Acyclicity is checked across kinds
+
+The cycle check runs on the union of both edge kinds, not on each kind separately. "A blocks B" together with "B before A" is contradictory as an ordering even though each edge is individually fine, so checking the kinds independently would admit orders that cannot be linearized.
+
+The check runs in the transaction that adds the edge: before inserting an edge from A to B, test whether A is already reachable from B through edges of either kind, and reject the insert if it is. Adding a parent separately walks the new parent's ancestor chain and rejects the change if the child appears in it.
+
+### Linearization
+
+Listing tasks sorts the filtered set with a stable topological sort — Kahn's algorithm over a priority queue. Among the tasks whose incoming edges are all satisfied, take the one that sorts first by `created_at`, then by `key`.
+
+The tiebreak has to be deterministic and documented, not incidental. A partial order can have several maximal elements, so "the top of the queue" is otherwise ambiguous, and the `ranger` skill instructs agents to pick up the top task when told to keep working. Oldest-first is the tiebreak that makes an unranked backlog behave like a queue.
+
+State grouping happens at display time: group by state, then linearize within each group. This replaces `ops::task::reorder`, which today rewrites a task's position when its state changes. `list --state done` continues to order by `done_at` and ignores edges entirely.
+
+### Stored edges are never reduced
+
+Inserting a task between two others creates redundant edges. Given `A before B`, placing X between them adds `A before X` and `X before B`, and `A before B` is now implied by the path through X.
+
+Leave it in place. Deleting it would lose information that was stated explicitly: if X is later deleted, A and B silently become unordered. Keeping every stated edge means deleting a task in the middle of a chain splices that chain automatically, because the edge spanning the gap is still there.
+
+Transitive reduction applies to *display* only — drawing the relationship graph without redundant edges — and the reduced graph is never written back.
+
+### Where petgraph fits
+
+Deriving order means running graph algorithms on every list command, which justifies a real graph library. petgraph covers two of the three needs:
+
+| Need | Source |
+|---|---|
+| Cycle detection on edge insert | petgraph, or a recursive CTE in SQL |
+| Transitive reduction for the graph view | petgraph (`algo::tred`) |
+| Stable topological sort | Hand-rolled; `algo::toposort` breaks ties by traversal order, not by `created_at` |
+
+Graph size is bounded by the number of tasks, so building it in memory per command costs nothing measurable at Ranger's scale. The mapping between task IDs and petgraph node indices has to be maintained on every build.
+
+### CLI surface
+
+`task move <key> -B <anchor>` and `-A <anchor>` keep their spelling and change their meaning: each one now records a `before` edge rather than computing a position string. Placing between two anchors records two edges.
+
+Because order is derived, a move can be a visual no-op — asking for a task to sit above something that blocks it cannot change the rendering. That case needs to say so rather than silently succeeding; see the `writing-error-messages` skill when wording it.
+
+### Migration
+
+Convert each backlog's `ready` tasks into a chain of `before` edges, in position order, using `lead()` over `position` partitioned by `backlog_id`. Then drop `tasks.position`.
+
+Only `ready` tasks get chained. Their order is the order that was deliberately maintained, so it's worth preserving exactly. Icebox tasks were mostly never ranked against each other — their positions are an artifact of insertion order, which the `created_at` tiebreak already reproduces — and chaining them would convert thousands of accidental positions into stated constraints that then have to be maintained by hand.
+
+Dropping `position` also deletes `src/position.rs` (174 lines), `ops::task::rebalance`, `ops::task::reorder`, and the position helpers `next_position_after`, `prev_position_before`, `last_position_in_state`, and `first_position_in_state`.
+
+### Relationships don't change state
+
+Relationships are displayed, not enforced on state. Ranger won't mark a parent done when its children finish, and it won't prevent a blocked task from being `ready`. Derived state tends to produce constraints like the existing `CHECK ((state = 'done') = (done_at IS NOT NULL))`, multiplied across the graph. Instead, `task show` lists blockers and subtasks, and the `blocked:` filter term makes `-blocked:true` list only unblocked work.
+
+Ordering is the one exception, and it is derived rather than stored, so nothing has to stay consistent on disk.
+
+## Affected code
+
+The following files reference backlogs, with approximate counts of the word `backlog`:
+
+| File | References | Change |
+|---|---|---|
+| `src/ops/task.rs` | 141 | Remove backlog scope from creation, listing, and lookup; delete the position helpers. |
+| `tests/cli.rs` | 45 | Rewrite backlog-based scenarios; add edge and linearization coverage. |
+| `src/bin/ranger/commands/task.rs` | 39 | Replace `--backlog` with filters; repoint `-B`/`-A` at edges. |
+| `src/bin/ranger/commands/serve.rs` | 29 | Replace the `/b/{name}` board route with a filtered view; the move API assumes per-backlog positions. |
+| `src/bin/ranger/commands/backlog.rs` | 29 | Delete. |
+| `src/ops/backlog.rs` | 22 | Delete. |
+| `src/ops/tag.rs` | 22 | Add key/value parsing; replace `list_for_backlog` with a filter-based query. |
+| `src/position.rs` | 0 | Delete. |
+
+`README.md`, `AGENTS.md`, and `skills/ranger/SKILL.md` describe backlogs and need updating. The skill also states that Ranger has no dependency model and tells agents to record blockers as comments; both instructions get replaced.
+
+The coverage gate (`just coverage` fails below 100%) applies to the filter parser, the cycle check, and the linearization tiebreak. That is more branching than fractional indexing had, and it is the bulk of the new test burden.
+
+## Rollout
+
+Each step leaves the build green and can ship on its own:
+
+1. Add `key` and `value` columns to `tags`. No behavior change.
+2. Add the filter language as shorthand over the existing flags, still scoped to a backlog.
+3. Mirror each backlog into a `project:` tag and write both on every change.
+4. Drop `backlog_id`, the backlog commands, and `RANGER_DEFAULT_BACKLOG`.
+5. Add `task_edges`, `parent_id`, and the cycle check, with order still coming from `position`.
+6. Switch listing to the derived order, convert `ready` positions to chains, and drop `position`.
+7. Add the graph view with transitive reduction.
+
+Steps 1 through 3 are reversible. Step 6 is the one that can't be undone without restoring positions from a backup.
+
+## Open questions
+
+- Why were `blockers`, `parent_id`, and `task_edges` dropped in migrations 005, 008, and 011? This proposal rebuilds the `009` schema almost exactly, so whatever made it not worth keeping applies here too.
+- Should the migration chain only `ready` tasks, as proposed above, or every task in position order? Chaining everything preserves today's list exactly, at the cost of turning incidental positions into stated constraints.
+- Is `created_at` the right linearization tiebreak, or should unranked tasks sort newest-first?
+- May a blocker edge connect a task to its own ancestor or descendant in the subtask tree?