1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# 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 three 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.
- Give each task a definition of done as its own field rather than prose buried in the description.
The changes are independent. None needs the others, and shipping the first two 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.
## Part 3: a definition of done
### Why it gets its own field
A task today has a title and a free-form description, so whatever says when the task is finished is mixed into the description, if it's written down at all. The `ranger` skill tells agents to pick up the top task and keep working, which makes "how do I know I'm done" the question an agent most needs answered and the one Ranger gives it least help with. A separate field makes the criteria something Ranger can display, prompt for, and filter on.
### Schema
Add one nullable column:
```sql
ALTER TABLE tasks ADD COLUMN done_when TEXT;
```
It's free-form text, like `description`. Structured checklists with per-item completion are out of scope; they would bring back the derived-state problem described in [Relationships don't change state](#relationships-dont-change-state). Because this is a plain `ADD COLUMN`, the migration doesn't recreate `tasks` and needs no `PRAGMA foreign_keys` dance. Existing tasks get `NULL`.
### CLI surface
- `task create` and `task edit` take `--done-when <text>`.
- `task show` prints the field after the description, labeled `Done when:`.
- The editor flow in `edit_in_editor` currently treats the first line as the title and everything else as the description. It needs a marker line, such as `Done when:`, that splits the body into description and definition of done. A missing marker leaves the field unchanged, and a marker with nothing under it clears the field.
- The filter language from Part 1 gains a term for tasks without a definition, such as `-has:done_when`, so the gaps are easy to find.
### Ranger records the definition but doesn't check it
Marking a task done doesn't verify the criteria; Ranger can't, and pretending to would be worse than not trying. The `ranger` skill changes instead: agents read `done_when` before starting a task and confirm each criterion before marking it done.
### Affected code
`migrations/`, `src/models.rs` (`Task`), `src/ops/task.rs` (`create` and `edit`), `src/bin/ranger/commands/task.rs` (flags, `print_task_detail`, and the editor parser), the task detail view in `serve.rs`, and `skills/ranger/SKILL.md`. The editor parser's marker handling is the main new branching under the coverage gate.
## 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.
Part 3 doesn't depend on either sequence and can ship before, between, or after them. Only the `has:` filter term waits for step 2.
## 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?
- Should moving a task to `ready` require a definition of done? That would make the field a gate rather than a convention, at the cost of breaking `task create --state ready` for quick captures.