Data model
The storage layer behind the app: what a task is, how it is ordered, how tags and links relate, who owns a row, how repeats become tasks, and how the schema moves.
Everything the app shows comes out of libs/storage, a SQLite database behind the toasty ORM. This page is the shape of that data. The UI-side view of the same features is in the user guide.
The task
Task is one row, and almost everything is a column on it rather than a side table: the deadline, the two priority multipliers, the subtask edge, and the fields the workflow engine and coding runs add.
| Field | Meaning |
|---|---|
id | Task identifier. Ids are generated with a `task` prefix by `derive_entity_id`. |
title, description | What the task is, and the longer note behind it. |
deadline | Unix timestamp the task is due. Unset means no deadline, and no deadline pressure. |
blocked_until | Time-based block. The task stays out of the list until this passes. |
importance_factor | User-adjustable f64 multiplier for priority. Defaults to 1.0. |
urgency_factor | User-adjustable f64 multiplier for priority. Defaults to 1.0. |
done, completed_at | Completion flag and when it was set. |
parent_id | Subtask edge. Subtasks are ordinary tasks with a parent. |
labels | JSON list of tags applied directly to this task. |
branch_name | The branch a coding task is being worked on. |
timezone | Captured at creation so deadlines and repeats are evaluated where they were set. |
comments | JSON list of comments pulled in from an external system. |
source_task_id | The task this one was derived from, when it came from another task. |
deleted_at | Tombstone. Set instead of deleting the row. |
workflow_run_id, node_id | Set when the workflow engine materialized this task as a step. |
role, spec_covered_at | Coding runs only: `role` marks an engine step, `spec_covered_at` records when the feature spec was taken to cover a subtask. |
Loading a task also computes the fields that are never stored. TaskWithMetais the row as the UI uses it:
| Field | Meaning |
|---|---|
task | The stored task. |
spec | The task's spec markdown, loaded from the namespaced `task_extra` store. `None` in list queries. |
direct_tags | Tags applied to this task itself. |
inherited_tags | Direct tags of the ancestor chain, so a subtask is tagged like its parents. |
inferred_tags | Tags reached through tag implications. |
leaf_tags | The most specific tags only: ancestors implied by another tag on the same task are omitted, which is what the task list displays. |
blocked | True when an unfinished blocker or a `blocked_until` timestamp keeps the task from being worked on. |
managed_by, managed_label | The managed app that owns the task, if any. |
deleted_at and leaves the row in place; queries add a deleted_at IS NULL filter rather than removing anything. Cancelling a
workflow run works the same way. Two things fall out of that: a sync mapping never points
at a row that has vanished underneath it, and something a user can no longer see can still
be the reason an external system needs updating.
Priority scoring
Order is not a stored position. It is a score, recomputed on load, and the list is sorted by it:
score = importance_factor × urgency_factor × deadline_pressuredeadline_pressure is what makes the list feel like it does. With no deadline it is 1.0. Before the deadline it is 86400 / seconds remaining, so pressure climbs hyperbolically as the task comes due and is exactly 86400 at the deadline. Past the deadline it keeps growing, linearly, so an overdue task does not sit at a plateau — a day late always outranks anything merely due today.
Because the score is derived, a drag-and-drop cannot just write an index. Dropping a task between two others calls factors_between(above, below), which solves for an importance and urgency pair whose score lands between the neighbours' live scores, using their geometric mean. Ordering survives edits to either neighbour without renumbering the list.
Tags and the tag tree
A tag is a name plus an optional display name, and label() prefers the display name. Project tags are named project:{path} and display the directory's name.
Tag implications form a DAG — an implier is a child of an implied tag — which is what makes nesting work. The sidebar still renders a tree, built from that DAG byTreeBuild: a depth-first walk that tracks the current path so a cycle cannot be re-entered, bounded by a maximum depth of 32 so a pathological graph cannot hang the renderer. Children are ordered projects first, then plain tags, then sections, and alphabetically within each group.
Per-tag settings live beside the tag, not on it: a sync target (which integration, and which object over there), the directories that back a directory-project, and the tag's sections in order. Sections are what let a generated checklist render under its own headings instead of as a flat list.
Links and blocking
Task-to-task relationships come in two kinds. Blocked by is a hard dependency: a task is blocked while any of its blockers is unfinished, and that blocked state is computed on read, never stored. After is a soft ordering hint — this should come after that, but nothing stops you.
Each kind forms its own DAG. Adding a link runs a breadth-first search for the target from the source, and a link that would close a cycle is rejected outright (a task linked to itself counts). The graph is never repaired afterwards because it can never be broken.
| Query | Returns |
|---|---|
blocker_ids(task_id) | The ids of the tasks that block this one. |
list_blockers(task_id) | The same, as full task rows. |
subtasks_map(task_ids) | Parent id to its direct subtasks. This is what lets the list render subtasks collapsed, with the first inline and the rest behind an N/M counter. |
Managed apps and ownership
Generated content — trip checklists, synced tasks, demo data — needs to be owned by something, so that regenerating it, or turning it off, is well-defined. That is the managed apps model, and it is deliberately one mechanism rather than one per feature.
An app is any entity that owns content on your behalf. It has a kind, a slug, a label, and an enabled flag:
| Kind | Owns |
|---|---|
recipe | Ships a workflow: the automations behind trips and other generated content. |
integration | Owns what a sync pulls in, such as a linked Todoist project. |
builtin | Content the app ships itself, such as demo data. |
An app attaches to a tag through a binding, whose role is either full_tag — the app owns the whole tag — or partial, where it manages some of the tag's sections or tasks while you own the rest. A binding also carries capture_new_tasks, which is what makes a task you add to a synced tag get picked up and pushed to the other side.
Ownership itself is a nullable managed_by column on tags, sections, and tasks. Null means you own it. On a task it is paired with a mode:
- Managed — the app owns the task. Content edits are blocked, completing it is still always allowed, and the app may replace the task when it regenerates.
- Captured — the app only propagates the task. It stays completely normal and editable, the app will not regenerate or remove it, and moving it out of the scope simply stops the syncing.
Two flags decide what happens when the two of you have both touched a task. managed_editable lets an app opt a managed task back into local edits.user_modified is set by any local content edit, and regeneration and removal skip a task that has it — so a deliberate edit is never silently overwritten. Completing or reopening a task deliberately does not set it, because that is not a content change.
The two halves of a managed row are visible in the UI: read-only fields carry a lock, and double-clicking to rename a managed row quietly does nothing rather than appearing to work.
check_managed_integrity walks the tables and reports each
kind of breakage:
orphaned_tag_owners— `tags.managed_by` points at an app that no longer exists.orphaned_section_owners— `tag_sections.managed_by` points at a missing app.orphaned_task_owners— `tasks.managed_by` points at a missing app.orphaned_recipe_apps, orphaned_integration_apps— A recipe or integration points at a missing app.dangling_binding_apps, dangling_binding_tags— An `app_tag_bindings` row points at a missing app or tag.
Repeats
A repeat is a template, not a task. RepeatTaskTemplate holds the rule, and the occurrences you see in the list are tasks materialized from it:
| Field | Meaning |
|---|---|
interval_days | How often the task repeats. 1 is daily, 7 is weekly. |
time_of_day | When on the occurrence date the repeat is scheduled. |
start_time_of_day | Written onto the materialized task as its `blocked_until`, so a repeat is not in the list before it is actually doable. |
weekdays, month_day | The weekdays for a weekly repeat, or the day of the month for a monthly one. |
strict | Whether a missed occurrence is skipped or caught up. |
timezone | The original zone the recurrence is evaluated in. |
blocked_by_template_id | Per-generation dependence: this occurrence is blocked by the same-date occurrence of another template. |
recipe_id | When set, the template schedules a workflow run instead of a task. |
Two of those fields are worth calling out because they are the parts that make repeats feel less like a calendar rule and more like a plan. start_time_of_day becomes the occurrence's blocked_until, which is why an 08:00 repeat is not sitting in your list at 07:00 waiting to be dismissed.
blocked_by_template_id gives per-generation dependence: this template's occurrence on a date is blocked by the same date's occurrence of another template. A daily task can therefore wait on another daily task — today's gym before today's shower — even though both are generated independently and neither knows about the order in the list.
Setting recipe_id turns a repeat into a schedule for a workflow run rather than a task, which is how a recurring automation is expressed. Demo content comes fromlibs/storage/src/testing.rs, which builds seed tags, seed tasks, and the assignments between them.
Generated content, end to end
Travel checklists are the worked example of the pieces above, and the only managed tag that ships today. An automation owns the tag: enabling it creates the tag, and disabling it removes the tag and everything the tag owns.
Each trip is a workflow run of the recipe, with the trip's name, length, and activities in the run's params. The checklist items are ordinary tasks carrying that run's workflow_run_id, tagged with the managed tag's section tags — “Pack” and “Before leaving” — so the existing task list renders a trip's checklist sectioned like any other tag with sections. The run completes when the checklist does.
The items themselves are derived from the trip's shape: base items every trip needs, extras that appear only for the activities you picked, and adjustments that depend on length — clothing scaled to the trip and capped at a week, with laundry suggested beyond that. Choosing days from a fixed set of lengths and activities from a fixed list is what keeps the generation predictable rather than a model's guess.
Migrations
The schema is versioned as numbered SQL files under libs/storage/toasty/migrations/, applied by the migration binary through the mig command in the dev shell. Reading them in order is a good way to watch a feature arrive: 0010_todoist_sync.sql brings the sync mapping tables, 0012_repeat_start_time.sql and 0013_repeat_dependency.sql add the two repeat fields above, 0017_coding_workflow.sql the coding run columns, and 0018_apps.sql with 0019_managed_scope.sql the ownership model.
The workflow engine builds on this data model to turn recipes into steps, and Architecture covers where these crates sit in the workspace.