The task graph
On this page
A new agent session knows nothing about the work it inherited. It has a fresh context window, a goal, and no memory of the decisions that led here. The task graph is what it reads instead of remembering: a durable, queryable structure that holds the shape of the work — which task is which, which phase blocks which, what was tried and learned — outside any one conversation. cairn keeps that structure in two stores, and they exist because the work poses two different questions.
Two stores, two questions
cairn answers two different questions, so it keeps two different stores.
The task store answers what is the structure of the work? It holds tasks as nodes and the typed relationships between them as edges. A task is a node addressed by a slug, a date-prefixed identifier minted at creation like 2026-06-22-add-fts-index, carrying a status, a description, edges to other tasks, and open metadata. Edges are typed: a phase belongs to a plan, one task depends on another, two tasks are related. This is a directed graph, and the query language (TQ) walks it.
The observation store answers what happened, and what was learned? It holds observations, the freeform notes an agent appends as it works, indexed for full-text search. Where the task store is structured and typed, the observation store is prose. An observation is the cheapest write cairn offers, and over a session there are many of them. You do not navigate them by edge; you search them by content.
The split is the point. Structure is small, slow-changing, and worth typing strictly. Notes are voluminous, fast-flowing, and worth indexing loosely. Forcing both into one shape would make the structure noisy or the notes rigid. Keeping them apart lets each store do one job well. An agent can ask a structural question (what is ready to work on?) without wading through prose, or a textual question (where did we hit the FTS5 tokenizer issue?) without walking edges.
Tasks are slug-addressed nodes
Every task has a slug, and the slug is its address everywhere: in edges, in queries, in handoffs, in the current-task pointer. Slugs are date-prefixed, so 2026-06-22- orders the day's work and the suffix names it. Because the address is a stable string rather than a numeric id, an agent can refer to a task by name across sessions without holding a handle, and a handoff written today still points at the right node tomorrow.
A task carries a small fixed set of fields the graph queries against, plus open metadata read as text. The fixed fields a TQ query can name are these eleven — six textual, two numeric, and three timestamps (universal-time, numeric-comparable and date-queryable):
| Type | Fields |
|---|---|
| text |
slug, status, description, depot, parent, display-name |
| number |
obs-count, edge-count |
| timestamp |
created-ts, updated-ts, status-ts |
Anything else you set with task_set_metadata is open metadata. It rides along on the node and reads as text, but it is not a typed field the query language type-checks. Status is a closed enum (open, active, completed, abandoned, blocked) split by the query layer into an active half (open, active, blocked) and a dormant half (completed, abandoned), which is how a source like (active) knows what to return.
Edges come in two classes
The graph has two kinds of edge, and the difference is not cosmetic: they are stored in different places and traversed differently.
Structural edges carry the fibration. A phase-of edge says one task is a phase of another, and it is folded into the child's parent pointer rather than stored as a separate edge row. task_fork writes exactly such a phase-of edge, its default edge_type, when it splits a child off a parent. The store recognizes a second structural type, forked-from, as an internal alias of that same parent pointer. Once folded, phase-of and forked-from are indistinguishable, which is why a task has exactly one structural parent. forked-from is not a value any tool accepts as edge_type; it exists only as a classification the graph reports. The backbone these types carry is the parent pointer, the plan-to-phase tree, while the order among sibling phases is tracked on a separate link. So "one structural parent" is a claim about ownership, not ordering.
Lateral edges are relations the backbone does not carry. depends-on and related live in a separate edge table, each a real row from a source task to a destination task. depends-on orders work: a phase that depends on another is not ready until the other settles. related is the catch-all link between two tasks that belong together without one owning the other. A task can have many lateral edges in either direction, and they are the cross-links over the structural tree. The store does not enforce acyclicity on them: a row from src to dst is rejected only when the two are the same task, so two tasks may each depend on the other. Traversal is built to tolerate that, which is why the (:closure) step is cycle-safe.
The closed edge vocabulary a tool accepts is exactly phase-of, depends-on, related. That is the set task_link and task_fork validate against, and an edge_type outside it is refused. The graph self-describes this split: the (edges) source returns each edge type tagged with its class, structural or lateral, so an agent can read the taxonomy off the graph rather than memorizing it.
(-> (edges) (:select :class))
4 tasks:
- depends-on class=lateral
- forked-from class=structural
- phase-of class=structural
- related class=lateral
A query that traverses an edge validates the edge type before it touches any data; an unknown edge type is a structured error, not a silent empty result. That distinction matters to a model. An empty result means nothing matched; an error means you asked the wrong question. cairn never lets one look like the other.
Observations index the prose
Observations are stored in their own table and mirrored into a full-text index built on SQLite's FTS5. The tokenizer keeps identifiers and file:line spans whole, treating underscores, colons, hyphens, and dots as token characters rather than separators, so a search for obs_fts or store.lisp:89 finds the note that mentions it. There is no stemming and there are no stopwords; what you wrote is what is indexed.
This is why the observation store is searched, not walked. An agent re-entering a task does not traverse to its observations; it queries them by content with task_search, which compiles its input into an FTS5 MATCH. The structured graph and the searched index meet at the task: each observation belongs to a task node, so obs-count is a queryable field on the node, and an agent can move from which task is busiest (a graph query) to what was said on it (a search) in two calls.
How the two stores stay one truth
Both stores are projections of a single append-only event log, not independent databases that could disagree. Every write, whether a task created, an edge linked, or an observation recorded, appends one event, and the task store and the observation store are folds of that same log. The graph you query is materialized state; the log is the truth it is derived from, and the projection can be rebuilt from the log at any time. That is the subject of events, projection, and reconcile, and it is why the two-store split never becomes a consistency problem: there is one source, and two views of it.
Related
- Tasks, observations, and handoffs — the three nouns the graph is built from
- Plans, phases, and the frontier — how
phase-ofanddepends-onedges make a plan you can query - Edges, statuses, and fields — the closed enums and the eleven queryable fields, in full
- Events, projection, and reconcile — why both stores are folds of one durable log
- The TQ language — how a query walks the typed-edge graph