Navigation
Copy page

The TQ query language

On this page

TQ is the S-expression query language cairn evaluates over the task graph. A query is one source form, or a source threaded through pipeline steps with ->. It is read with *read-eval* disabled and walked, never evaluated, then lowered onto SQL over the task projection plus a few in-memory folds. Every result renders to text before it leaves the runner, so nothing but a string crosses the tool boundary, and every failure is a structured Query error: or Parse error: rather than a backtrace.

Two tools run TQ: task_query reads, and task_query_write reads and writes. The language is the same on both; one dynamic gate decides whether a mutation form is allowed.

Synopsis

text
QUERY  := SOURCE | (-> SOURCE STEP...)
SOURCE := (all) | (active) | (dormant) | (current)
        | (node "<substr>") | (query "<name>")
        | (schema) | (views) | (fields) | (edges)
        | (define! "<name>" QUERY) | (undefine! "<name>")   # write surface only
STEP   := (<keyword> ARG...)

A query is exactly one S-expression. The bare -> head opens a pipeline whose first element is a source and whose rest are steps applied left to right. A step is a keyword form like (:follow :phase-of); a bare keyword such as :ids is rejected — every operator is a form. A source carrying the wrong arity or an empty string argument errors rather than returning an empty set.

Read versus write

The !-suffix marks every mutation. task_query binds the gate off, so any !-form — a (:set-status! ...) step or a (define! ...) source — is refused:

text
Query error: Mutations need the task_query_write surface; task_query is read-only.

task_query_write binds the gate on and accepts them. The gate keys on the !-suffix and the step's :mutation kind, not on which tool is registered, so a write can never slip through the read surface. Inside set-operation operands and inside (define! "name" Q) the gate is forced off regardless of surface: you combine and define over selections, you do not mutate inside them. A mutation form within an operand is refused even under task_query_write. See Reads, writes, and capabilities.

Sources

A source is a nullary producer of a node-set. A node is a slug paired with its hydrated properties. The four broad sources exclude the reserved @-prefixed namespace (the @cairn view node); real task slugs are date-prefixed, so nothing a user created is dropped, and (node ...) and (current) still address reserved nodes.

Form Yields Notes
(all) Every task. Excludes the reserved namespace.
(active) Tasks with status open, active, or blocked. The live half of the status enum.
(dormant) Tasks with status completed or abandoned. The settled half.
(current) The current task. Errors when no task is selected: This query needs a current task; select one first.
(node "substr") Tasks whose slug contains the substring. Case-insensitive; a non-empty pattern is required.
(query "name") The named view resolved to its node-set. An unknown name errors and enumerates the available views.
(schema) The source/step/write grammar as a node-set. Reflective; see below.
(views) The named views, built-in and user, as a node-set. Reflective.
(fields) The queryable fields and their value types. Reflective.
(edges) The edge vocabulary and each type's class. Reflective.
(define! "name" Q) Records Q as a named view; returns Q's tasks. Write surface only.
(undefine! "name") Removes a user view, restoring any built-in; returns nothing. Write surface only.

recent is a view, not a source: reach it with (query "recent"). There is no (recent) source form. The list of resolvable view names lives in Views.

Steps

A step transforms or shapes the running node-set. Transformers map a node-set to a node-set and compose; shapers terminate the pipeline into a final value (a projection, a group, a slug list, or a count); mutations write and return their input so they keep composing. A shaper must be last — a transformer after a terminal value errors with This step expects a set of tasks, but the previous step produced a final value.

Transformers map a node-set to a node-set, so they chain.

(:follow EDGE)

The tasks one EDGE hop forward. Structural edges (phase-of, forked-from) traverse the parent foreign key; lateral edges (depends-on, related) traverse the edges table. The edge is validated first, so an unknown type errors even over an empty input — the edge is a property of the query, not of the data.

lisp
(-> (node "auth") (:follow :phase-of) (:ids))

(:back EDGE)

The tasks one EDGE hop backward — the inverse of :follow. From a forked child, (:back :phase-of) reaches its parent.

lisp
(-> (node "phase-3") (:back :phase-of) (:ids))

(:where PRED)

The tasks satisfying predicate PRED. A predicate that references a non-local field auto-enriches the set on demand; a predicate over a base field reads it directly without enrichment. See Predicates.

lisp
(-> (active) (:where (= :status "blocked")) (:ids))

(:sort FIELD [:asc|:desc])

The node-set ordered by FIELD — numeric when the field holds numbers, lexical otherwise. The direction is optional and defaults to :desc; pass :asc to ascend. A direction that is neither :asc nor :desc is a structured error with a suggestion, not a silently ignored argument.

lisp
(-> (active) (:sort :updated-ts))         ; most-recent first — the default
(-> (active) (:sort :slug :asc) (:ids))   ; A→Z by slug
text
Query error: :sort needs :asc or :desc; did you mean :asc?

(:take N)

The first N tasks. N must be an integer; a negative N clamps to zero.

lisp
(-> (active) (:sort :obs-count) (:take 10))

(:enrich)

Add counts and promoted metadata (:obs-count, :edge-count, and any metadata keys) to each node, so fields beyond the hydrated base become readable and printable.

lisp
(-> (current) (:enrich))

(:union Q)

Tasks in the pipeline or in sub-query Q, deduplicated by slug. Q must itself yield a node-set, evaluated read-only.

lisp
(-> (active) (:union (dormant)) (:count))

(:intersect Q)

Tasks in both the pipeline and sub-query Q.

lisp
(-> (all) (:intersect (active)) (:count))

(:minus Q)

Pipeline tasks not in sub-query Q.

lisp
(-> (all) (:minus (active)) (:ids))

(:or-else Q)

The pipeline if it holds any task, else sub-query Q. Used by the plan view to fall back from a root's children to its siblings.

lisp
(-> (current) (:follow :phase-of) (:or-else (active)))

(:closure EDGE...)

The forward transitive closure over one or more edge types, cycle-safe and bounded to depth 5. The starting nodes are excluded unless a cycle leads back to one. Each edge is validated eagerly. The knowledge view is (:closure :phase-of :depends-on :related).

lisp
(-> (current) (:closure :phase-of :depends-on :related) (:enrich))

Shapers end the pipeline in a final value and must come last. The text below each shaper is exactly what the runner returns.

(:select FIELD...)

Project the named fields; at least one field is required. Every selected field is validated against the field vocabulary, so an unknown field is a structured error with a nearest-match suggestion rather than a silently dropped column:

lisp
(-> (all) (:select :updated-at))
text
Query error: Unknown field :updated-at; did you mean :updated-ts?

A projected field that is not already on the node — a count or a promoted metadata key — is enriched on demand, so it shows its value instead of nothing. The result is rectangular: every selected field prints on every row, an absent value as the empty-set glyph , and a timestamp field as its raw universal-time integer followed by the decoded UTC date.

lisp
(-> (node "auth") (:select :display-name :status))
text
1 task:
- 2026-04-02-auth-rework  display-name=Auth rework  status=active
lisp
(-> (node "rate-limit") (:select :slug :obs-count :updated-ts :parent))
text
1 task:
- 2026-04-05-rate-limit  slug=2026-04-05-rate-limit  obs-count=4  updated-ts=3984388200 (2026-04-05)  parent=∅

(:group-by FIELD)

Bucket the node-set by FIELD value, largest bucket first.

lisp
(-> (active) (:group-by :status))
text
2 groups:

## active (2)
- 2026-04-02-auth-rework [Auth rework] (active)
- 2026-04-05-rate-limit (active)

## blocked (1)
- 2026-03-30-token-store (blocked)

(:ids)

The slugs only, one per line, under a count line.

lisp
(-> (active) (:ids))
text
3 tasks:
- 2026-03-30-token-store
- 2026-04-02-auth-rework
- 2026-04-05-rate-limit

The count line pluralizes: a one-task result reads 1 task:, and an empty result is the single line No matching tasks.

(:count)

The cardinality, as a bare number with no surrounding text.

lisp
(-> (all) (:count))
text
2

Mutations are transformers that also write. A mutation records one event per real task through the durable event log and returns its input set, so (:count) after it reports the size of the set it ran over — equal to the number of real tasks touched when no synthesized nodes are present. A synthesized node — a grammar node from a reflective source — carries no task id and is passed over, never minting a phantom, yet stays in the returned set. Events are append-only and their projection effects converge, so re-running a mutation is safe. Every mutation requires the task_query_write surface.

(:set-status! STATUS)

Set each task's status. STATUS must be one of open, active, completed, abandoned, blocked.

lisp
(-> (active) (:set-status! "completed") (:count))
text
3

(:set! :key "value")

Set a metadata field on each task. The key is a field keyword; it refuses :slug and :status (status has its own step).

lisp
(-> (current) (:set! :objective "ship it"))

Add an edge from each task to the constant target slug. :edge is one of phase-of, depends-on, or related — the same closed enum the task_link tool takes. A depends-on or related link lands in the edges table; a phase-of link is structural and folds into the parent foreign key rather than the lateral table, exactly as the edges reference describes. A task equal to the target is skipped — a task does not edge to itself.

lisp
(-> (node "phase-2") (:link! :depends-on "phase-1"))

Remove the edge from each task to target. :edge is one of phase-of, depends-on, or related, matching (:link!).

lisp
(-> (node "phase-2") (:unlink! :depends-on "phase-1"))

Predicates

A predicate is the argument to (:where ...) and to quantifiers. It is a form of (slug props) and composes with boolean combinators.

Form Meaning
(= LHS v) LHS equals literal v. LHS is a field keyword or (count TRAV).
(has :field) :field is present and non-null on the node.
(matches :field "substr") :field's value contains the case-insensitive substring.
(> LHS n) LHS is greater than real number n.
(< LHS n) LHS is less than n.
(>= LHS n) LHS is at least n.
(on :ts "YYYY-MM-DD") timestamp field :ts falls on the given UTC day.
(since :ts "YYYY-MM-DD") :ts is on or after the given UTC day.
(before :ts "YYYY-MM-DD") :ts is before the given UTC day.
(and P...) Every sub-predicate holds.
(or P...) Some sub-predicate holds.
(not P) P does not hold.
(all TRAV P) P holds for every task one TRAV hop away.
(any TRAV P) P holds for some task one TRAV hop away.
(none TRAV P) P holds for no task one TRAV hop away.

In a quantifier, TRAV is (:follow EDGE) or (:back EDGE). The traversal's edge is validated eagerly, so an ill-typed quantifier errors even over an empty focus set. Over a focus node with no neighbours, all and none are vacuously true and any is false.

Numeric comparisons are type-checked. The left side of >, <, or >= must be a numeric field (one of created-ts, updated-ts, status-ts, obs-count, edge-count) or (count TRAV), and the operand a real number; comparing a text field numerically is an error, not a silent empty match. (count TRAV) is the cardinality of a relative traversal, usable wherever a numeric left side is.

lisp
(-> (node "release") (:where (> (count (:follow :depends-on)) 1)) (:ids))

The full field vocabulary and which fields are textual versus numeric is in Edges, statuses, and fields.

Dates and timestamps

created-ts, updated-ts, and status-ts are :timestamp fields: CL universal-time, an integer count of seconds since 1900-01-01 UTC. They order as integers and read as dates. The on / since / before predicates window the clock by a calendar day so a date intent is one predicate, not a reverse-engineered range. Each parses a strict YYYY-MM-DD operand at UTC midnight; on matches the 24-hour day [day, day+1), since matches ≥ day, and before matches < day.

lisp
(-> (active) (:where (on :updated-ts "2026-06-24")) (:ids))
(-> (active) (:where (since :created-ts "2026-06-01")) (:count))

A date predicate is gated to timestamp fields, and a malformed date is a structured error rather than a silent empty result:

text
Query error: on needs a timestamp field; :status is text.
Query error: "not-a-date" is not a YYYY-MM-DD date.

Because a timestamp orders as an integer, the numeric predicates compare it too. An integer operand to >, <, or >= against a timestamp field is read as Unix seconds and lifted onto the same universal-time clock the write path uses, so a Unix-epoch literal lands where you mean it instead of below every value. (< :updated-ts 2500000000) compares against the lifted instant, not the raw integer.

Unknown names suggest a fix

Every dispatched name — a field, a predicate, a step, a source, an edge type — is validated against its vocabulary, and an unknown one is a structured error carrying the nearest match within a small edit distance, or the full list when nothing is close. The query never silently misfires on a typo.

text
Query error: Unknown predicate matchez; did you mean matches?
Query error: Unknown step :zelect; did you mean :select?
Query error: Unknown source recent; known: active, all, current, dormant, edges, fields, node, query, schema, views.

Reflective forms

(schema), (fields), (edges), and (views) are sources that render the language's own vocabularies as node-sets in the same carrier task queries produce, so every step composes over them. The slugs are bare names, never date-prefixed, so they never collide with a task, and a task traversal over a grammar node is an honest empty set rather than an error. Each list is generated from the registry the interpreter dispatches on, so the description cannot drift from behaviour.

Source Each node carries
(schema) :category (source/step/predicate/write); :kind on steps (transformer/shaper/mutation) and predicates (leaf/combinator/quantifier); :signature (the machine-readable argument contract) on steps and predicates; and :doc.
(fields) :origin (declared or metadata), :type (text, number, or timestamp), and on a timestamp field :unit and :epoch.
(edges) :class (structural or lateral).
(views) :origin (builtin or user) and :source (the view's TQ text).

(schema) lists predicates alongside sources and steps, so the predicate vocabulary — including the date predicates and their signatures — is discoverable from the one call. There is no (help) source; (schema) is the canonical entry point to the grammar.

Probe the grammar with the algebra:

lisp
(-> (schema) (:where (= :kind "shaper")) (:ids))
(-> (schema) (:where (= :category "predicate")) (:select :kind :signature))
(-> (fields) (:where (= :type "timestamp")) (:ids))
(-> (edges) (:where (= :class "structural")) (:ids))
(-> (views) (:where (= :origin "builtin")) (:ids))

The first lists the shapers by their bare names, sorted with the rest of the carrier:

text
4 tasks:
- count
- group-by
- ids
- select

The second reads the predicate vocabulary with its kinds and signatures — for example the three date predicates share the signature field-ts lit-date, so (-> (schema) (:where (= :signature "field-ts lit-date")) (:ids)) recovers before, on, and since.

Reflective sources run on the read surface — they describe, never mutate — and stay total. Use (:select ...) to project a field, for example (-> (schema) (:where (= :category "step")) (:select :doc)).

Worked examples

Each program is one full query. Read-surface examples run under either tool; write-surface examples require task_query_write. The text fence under each query is the runner's actual output.

lisp
(-> (active) (:where (not (has :parent))))
text
2 tasks:
- 2026-04-02-auth-rework [Auth rework] (active)
- 2026-04-05-rate-limit (active)

Read. Active tasks with no parent — the active-roots view: every live root in the graph. With no (:ids) or (:select ...) shaper the result is the node-set itself, one - slug [display] (status) line per task; :parent is a base field, so the predicate reads it without enrichment and the lines carry no obs=/edges= counts. Add (:enrich) before the implicit render to print them.

lisp
(-> (current) (:follow :phase-of) (:ids))
text
3 tasks:
- 2026-04-02-auth-rework-phase-1
- 2026-04-02-auth-rework-phase-2
- 2026-04-02-auth-rework-phase-3

Read. The phases directly under the current task.

lisp
(query "plan-frontier")

Read. The phases of the current plan that are not done and whose every depends-on dependency is settled — the ready work. (query "frontier") is an alias.

lisp
(-> (node "auth") (:where (matches :description "oauth")) (:ids))

Read. Tasks whose slug contains auth and whose description mentions oauth.

lisp
(-> (active) (:union (dormant)) (:count))
text
2

Read. The size of the whole task set, by the status partition.

lisp
(-> (current) (:closure :phase-of :depends-on :related) (:enrich))

Read. The transitive neighbourhood of the current task across every edge — the knowledge view.

lisp
(-> (active) (:set-status! "completed") (:count))
text
3

Write. Complete every active task and report how many were touched; re-running over the now-empty active set touches nothing and returns 0.

lisp
(-> (node "phase-2") (:link! :depends-on "phase-1"))

Write. Add a depends-on edge from each phase-2 task to phase-1. The link step returns its input set, so the rendered output is the touched phase-2 nodes.

lisp
(define! "blocked-work" (-> (active) (:where (= :status "blocked"))))

Write. Record a user view named blocked-work; it then resolves on the read surface via (query "blocked-work") and shadows any built-in of the same name. (undefine! "blocked-work") removes it.