---
name: Storage tasks and subtasks CRUD
type: architecture-map-page
slice: 05-core-storage-hotkey-build
last_verified: 2026/05/09
---

# Storage tasks and subtasks CRUD

> **Where you are:** [Architecture map](../README.md) → [Core, Storage, Hotkey, Build](README.md) → Storage tasks CRUD

**Plain English summary.** Tasks are the things the user wants to do. Subtasks are tasks with a `parent_task_id` set. Both live in the same `tasks` table with a self-referential foreign key. The completion path includes a small bit of business logic: completing the last subtask auto-completes the parent (and stamps `auto_completed = 1` so the daily-completion analytics can exclude cascade-completed parents).

## At a glance

- Source: `crates/storage/src/database.rs:301-631`.
- Public surface: `insert_task`, `list_tasks`, `get_task_by_id`, `update_task`, `set_task_energy`, `complete_task`, `uncomplete_task`, `delete_task`, `insert_subtask`, `list_subtasks`, `complete_subtask_and_check_parent`, `list_recent_completions`.
- Public types: `TaskRow`, `DailyCompletionCount`.
- Consumers: slice 2 task commands; the LLM micro-step decomposer (slice 4) calls `insert_subtask`; the gamification badge on the Tasks page reads `list_recent_completions`.

## Public types

### `TaskRow` — `crates/storage/src/database.rs:825`

```rust
pub struct TaskRow {
    pub id: String,
    pub text: String,
    pub bucket: String,
    pub list_id: Option<String>,
    pub effort: Option<String>,
    pub notes: String,           // v4
    pub done: bool,
    pub done_at: Option<String>,
    pub created_at: String,
    pub source_transcript_id: Option<String>,
    pub parent_task_id: Option<String>,  // v3
    pub energy: Option<String>,          // v11; "high" | "medium" | "brain_dead" | None
}
```

### `DailyCompletionCount` — `crates/storage/src/database.rs:573`

```rust
#[derive(serde::Serialize)]
pub struct DailyCompletionCount {
    pub day: String,   // "YYYY-MM-DD" in local time
    pub count: u32,
}
```

Serialised because slice 2 forwards it to the frontend sparkline.

## Functions

### `insert_task(pool, id, text, bucket, source_transcript_id, list_id, effort, energy)` — `crates/storage/src/database.rs:301`

Positional signature, eight arguments. Documented at `database.rs:293` as deliberately flat — it mirrors the `tasks` schema columns. Refactor to a params struct only if another nullable lands after `energy`.

### `list_tasks(pool) -> Result<Vec<TaskRow>>` — `crates/storage/src/database.rs:328`

`SELECT ... FROM tasks ORDER BY created_at DESC`. Returns every row, including completed ones. Frontend filters.

### `get_task_by_id(pool, id) -> Result<Option<TaskRow>>` — `crates/storage/src/database.rs:341`

Single-row select.

### `update_task(pool, id, text, bucket, list_id, effort, notes, energy)` — `crates/storage/src/database.rs:361`

Updates the user-editable fields. `done` and `done_at` are not updated through this function — completion has its own pair (`complete_task` / `uncomplete_task`).

### `set_task_energy(pool, id, energy) -> Result<TaskRow>` — `crates/storage/src/database.rs:402`

Single-column update. Returns the row post-update so the frontend gets confirmation. Energy must be one of `"high"`, `"medium"`, `"brain_dead"`, or `None` — enforced by the DB-level `CHECK` from migration v11.

### `insert_subtask(pool, parent_id, id, text, bucket)` — `crates/storage/src/database.rs:415`

Same as `insert_task` but with `parent_task_id` set. Inherits the `bucket` from the parent.

### `list_subtasks(pool, parent_id) -> Result<Vec<TaskRow>>` — `crates/storage/src/database.rs:431`

`SELECT ... FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`.

### `complete_subtask_and_check_parent(pool, subtask_id) -> Result<()>` — `crates/storage/src/database.rs:447`

Business logic, in one function so it lives in one transaction:

1. Mark the subtask done.
2. If every sibling subtask is now done, mark the parent done with `auto_completed = 1`.

The `auto_completed` flag (v13) lets `list_recent_completions` exclude cascade-completed parents from the daily-count sparkline so the user sees one count per real action, not one count per parent action plus one per subtask completion.

### `complete_task(pool, id)` — `crates/storage/src/database.rs:498`

`UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?`.

### `uncomplete_task(pool, id)` — `crates/storage/src/database.rs:507`

`UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?`.

This is the function flagged in the 2026-04-22 review as RB-15 ("doesn't reopen auto-completed parents"). The current implementation only un-completes the row passed in. If a user un-completes a child of an auto-completed parent, the parent stays done. The fix is to walk up the `parent_task_id` chain and reset auto-completed parents whose subtasks are no longer all-done. Tracked as a release-blocker (per `code-review-2026-04-22.md`); resolution status to verify against current `HANDOVER.md`.

### `delete_task(pool, id)` — `crates/storage/src/database.rs:550`

`DELETE FROM tasks WHERE id = ?`. The `parent_task_id REFERENCES tasks(id) ON DELETE CASCADE` from migration v3 removes child subtasks automatically.

### `list_recent_completions(pool, days) -> Result<Vec<DailyCompletionCount>>` — `crates/storage/src/database.rs:578`

The Phase 8 momentum sparkline. Steps:

1. Clamp `days` to `[1, 365]`.
2. Group `tasks` rows by `DATE(done_at, 'localtime')` for `done = 1 AND auto_completed = 0`.
3. Compose a fixed-length output array by left-joining the grouped result against an N-day "spine" generated by re-querying SQLite for each offset (so SQLite owns the calendar arithmetic, including DST and month boundaries).

Today the frontend always asks for `days = 7`. The clamp prevents a wild value producing an empty or huge series.

## Watch-outs

- **`uncomplete_task` does not reopen auto-completed parents.** RB-15 in the 2026-04-22 review. Status: see slice debt section in [`README.md`](README.md).
- **`list_tasks` returns every task ever created.** Soft-delete is not modelled; deleted tasks are gone. Completed tasks pile up over time. Frontend is responsible for filtering.
- **Energy column accepts NULL but the `CHECK` only fires on non-NULL values.** Migration v11's check is `CHECK (energy IN ('high', 'medium', 'brain_dead') OR energy IS NULL)`. Worth a regression test that asserts an invalid string fails.
- **`auto_completed` flag is set in code, not via trigger.** A future migration that adds another path to "complete this task" (eg an LLM-driven auto-complete) needs to remember to set the flag. Worth a row-level audit.

## See also

- [Schema and migrations](storage-schema-and-migrations.md) — v3, v4, v11, v13 are the relevant migrations.
- [Storage overview](storage-overview.md)
- [Slice 4 LLM decompose-task](../04-llm-formatting-mcp/README.md) — caller of `insert_subtask`.
