Edges, statuses, and fields
On this page
cairn's task graph is built from three closed vocabularies: the edge types that connect tasks, the statuses a task moves through, and the fields a query reads. Each is a small fixed set defined in one place in the source, so a tool, a query predicate, and a stored CHECK constraint can never disagree about what is legal. This page is the catalogue of those sets. It also covers the reflective sources — (edges), (fields), (schema), (views) — that let a query read the catalogue back from the running store.
A field absent from the typed set is not an error: it is open metadata, set with task_set_metadata and read as text. The closed sets below are the ones the language understands well enough to validate, classify, and type-check.
Edge types
An edge connects two tasks. cairn stores edges two ways, and which way an edge is stored decides how a query traverses it.
A lateral edge lives in the edges table as an explicit row from a source task to a target task. A structural edge names the task fibration — a child's single parent — and folds into the child's parent_task_id, never the edges table. A query traverses a lateral edge by joining the edges table and a structural edge by walking the parent foreign key. The surface verb is the same (:follow / :back); the storage and the cardinality differ. The task graph page covers why the two are stored apart.
| Edge type | Class | Accepted by tools | Notes |
|---|---|---|---|
phase-of | structural | yes |
A phase belongs to its parent plan. Folded into the child's parent_task_id; a task has at most one parent. Default edge_type for task_fork. |
depends-on | lateral | yes | This task waits on its target. The ordering edge the frontier reads to decide readiness. |
related | lateral | yes | A loose association with no ordering or ownership meaning. |
forked-from | structural | no |
A structural alias of the parent pointer that the traversal layer recognizes; in the store it is indistinguishable from phase-of (both fold into parent_task_id). It is never the edge a fork writes — a fork records phase-of by default — and never a value you pass as edge_type. |
The tool-accepted vocabulary
task_link, task_sever, and task_fork accept exactly three edge_type values: phase-of, depends-on, related. The check is case-insensitive — Depends-On is accepted and lowercased — and anything outside the set is refused before the write:
"blocks" is not a valid edge type; expected one of phase-of, depends-on, related.
blocks and blocked-by look like they should work, and they read cleanly, but they are not part of the tool-accepted vocabulary. Internally cairn projects both onto depends-on while preserving the original name in a tag; that projection is an implementation detail of how a raw edge is normalized, not a value the tools advertise. Pass depends-on directly.
Traversal classes
A query traversal classifies its edge keyword the same way before it runs, and an unrecognized edge is a structured error rather than a silently empty result — the edge is a property of the query, not of the data. The error carries the nearest known type, or the full list when nothing is close:
Unknown edge type spawned-by; known: :depends-on, :forked-from, :phase-of, :related.
Note the traversal vocabulary is wider than the tool vocabulary: a query may traverse forked-from (and phase-of) because both name the structural fibration, even though only phase-of is a tool-accepted edge_type. The mutation steps :link! / :unlink! narrow back to the three tool-accepted edge_type values (phase-of, depends-on, related), mirroring the write tools.
Statuses
Every task carries exactly one of five statuses. The set is closed and the SQLite CHECK constraint mirrors it, so an invalid status cannot reach the store.
| Status | Half | Meaning |
|---|---|---|
open | active | Created, not yet started. |
active | active | In progress. |
blocked | active | Stalled on an external condition; still live work. |
completed | dormant | Finished and settled. |
abandoned | dormant | Settled without completion; will not be resumed. |
The status enum partitions into an active half (open, active, blocked) and a dormant half (completed, abandoned). This is the partition the query sources read: (active) yields the live half and (dormant) yields the settled half. A plan's frontier is then ready precisely when a phase is not dormant and all of its depends-on targets are. The partition is checked at load time against the full enum, so a status can never be live and settled at once, nor fall outside both halves.
task_update_status lowercases its status argument and validates it against the set; anything else is refused:
"done" is not a valid status; expected one of open, active, completed, abandoned, blocked.
completed and abandoned are not terminal in the data — a task is reopened by setting it active again, and the reopen flag on task_update_status is shorthand for exactly that. They are terminal only in the sense that the dormant half drops out of the active sources until you move it back.
Fields
A field is a named, typed value a query reads off a task. Eleven fields are declared with a type; the type is what lets a numeric predicate refuse a textual field instead of comparing garbage, and a date predicate refuse anything but a clock.
| Field | Type | Source |
|---|---|---|
slug | text | The task's date-prefixed identifier; the node's own name. |
status | text | The current status (see above). |
description | text | The freeform description set at creation. |
depot | text | The depot the task belongs to. |
parent | text | The slug of the structural parent, or absent for a root. |
display-name | text | A human-readable label; defaults to the slug. |
created-ts | timestamp | Creation time, universal-time seconds. |
updated-ts | timestamp | Last-touched time, universal-time seconds. |
status-ts | timestamp | Time of the last status change. |
obs-count | number | Number of observations recorded on the task. |
edge-count | number | Number of edges incident to the task (lateral plus structural). |
The six text fields are slug, status, description, depot, parent, display-name. The two number fields are obs-count and edge-count. The three timestamp fields are created-ts, updated-ts, and status-ts.
The type drives predicate type-checking: (> :obs-count 3) compiles because obs-count is numeric, while (> :status 1) is rejected as ill-typed before it touches a row:
(-> (active) (:where (> :status 1)))
Query error: > needs a numeric field; :status is text.
A timestamp is CL universal-time — an integer count of seconds since the epoch 1900-01-01 UTC. It is numeric-comparable, so it orders and compares as an integer ((> :updated-ts 0) is well-typed), and it is the only type the date predicates on / since / before accept, letting (on :updated-ts "2026-06-24") express a calendar-day intent directly. When projected with :select, a timestamp renders as its raw integer and its decoded UTC date — updated-ts=3984388200 (2026-04-05) — so the integer clock stays legible. The unit and epoch are not buried in this page: (fields) carries them on every timestamp field, below.
Any field not in this table is open metadata — a key written with task_set_metadata or the :set! step. It is read on demand and treated as text, so (matches :owner "mika") works against a metadata key cairn has never heard of; only the declared eleven get a numeric or timestamp type. The counts obs-count and edge-count, and any promoted metadata, are added by enrichment (:enrich), which a query inserts automatically when a step references a field that is not already local.
Reflective sources
The three vocabularies above are queryable from the running store, not only documented here. Four sources synthesize the catalogue into the same node-set carrier that task queries produce, so every TQ step composes over them for free. The names they yield are bare (never date-prefixed), so they never collide with a real task. Each source is generated from the registry the interpreter dispatches on, so the readout cannot drift from behavior.
| Source | Yields | Per-node props |
|---|---|---|
(edges) | The edge vocabulary, one node per type |
:class — structural or lateral |
(fields) | The declared fields plus any live metadata keys |
:origin (declared / metadata), :type (text / number / timestamp), and :unit + :epoch on a timestamp |
(views) | The named views, built-in and user |
:origin (builtin / user), :source (TQ text) |
(schema) | The combinator grammar: every source, step, predicate, and write form |
:category (source / step / predicate / write), :kind (on steps and predicates), :signature (on steps and predicates), :doc |
(edges) lists all four known edge types — the three tool-accepted plus forked-from — because the traversal surface understands all four. To recover only the tool-accepted lateral set, filter on the class:
(-> (edges) (:where (= :class "lateral")) (:ids))
2 tasks:
- depends-on
- related
The same algebra reads the fields. List every numeric field, or every timestamp field:
(-> (fields) (:where (= :type "number")) (:ids))
2 tasks:
- edge-count
- obs-count
(-> (fields) (:where (= :type "timestamp")) (:ids))
3 tasks:
- created-ts
- status-ts
- updated-ts
The clock is self-describing: project a timestamp field's :unit and :epoch instead of reverse-engineering them from raw values.
(-> (fields) (:where (= :type "timestamp")) (:select :type :unit :epoch))
3 tasks:
- created-ts type=timestamp unit=seconds epoch=1900-01-01 UTC (CL universal-time)
- status-ts type=timestamp unit=seconds epoch=1900-01-01 UTC (CL universal-time)
- updated-ts type=timestamp unit=seconds epoch=1900-01-01 UTC (CL universal-time)
(fields) also lists the open metadata keys live in the store, each tagged :origin "metadata" (declared fields are :origin "declared"), so a query discovers the metadata vocabulary it can filter and project without reading any code — (-> (fields) (:where (= :origin "metadata")) (:ids)).
(schema) describes the grammar itself, including the reflective sources — it lists edges, fields, views, and schema among its source nodes, and it lists the predicate vocabulary (each predicate tagged :category "predicate" with its :kind and :signature) alongside the sources and steps. That makes the language self-documenting: an agent that has the (schema) source can discover the rest of the surface without leaving the query, which is the same loop these docs serve for a human reader. There is no (statuses) source; the status partition is documented above and surfaces through (active) / (dormant) rather than a reflective list.
Related
- Tools — the writers that consume these enums:
task_link,task_fork,task_update_status,task_set_metadata. - The TQ language — the sources, steps, and predicates that read fields and traverse edges.
- Views — the built-in named queries phrased over these vocabularies.
- The task graph — why structural and lateral edges are stored differently.
- Plans, phases, and the frontier — how
phase-of,depends-on, and the status partition combine into readiness.