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
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:
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.
(-> (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.
(-> (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.
(-> (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.
(-> (active) (:sort :updated-ts)) ; most-recent first — the default
(-> (active) (:sort :slug :asc) (:ids)) ; A→Z by slug
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.
(-> (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.
(-> (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.
(-> (active) (:union (dormant)) (:count))
(:intersect Q)
Tasks in both the pipeline and sub-query Q.
(-> (all) (:intersect (active)) (:count))
(:minus Q)
Pipeline tasks not in sub-query Q.
(-> (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.
(-> (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).
(-> (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:
(-> (all) (:select :updated-at))
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.
(-> (node "auth") (:select :display-name :status))
1 task:
- 2026-04-02-auth-rework display-name=Auth rework status=active
(-> (node "rate-limit") (:select :slug :obs-count :updated-ts :parent))
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.
(-> (active) (:group-by :status))
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.
(-> (active) (:ids))
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.
(-> (all) (:count))
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.
(-> (active) (:set-status! "completed") (:count))
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).
(-> (current) (:set! :objective "ship it"))
(:link! :edge "target")
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.
(-> (node "phase-2") (:link! :depends-on "phase-1"))
(:unlink! :edge "target")
Remove the edge from each task to target. :edge is one of phase-of, depends-on, or related, matching (:link!).
(-> (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.
(-> (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.
(-> (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:
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.
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:
(-> (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:
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.
(-> (active) (:where (not (has :parent))))
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.
(-> (current) (:follow :phase-of) (:ids))
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.
(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.
(-> (node "auth") (:where (matches :description "oauth")) (:ids))
Read. Tasks whose slug contains auth and whose description mentions oauth.
(-> (active) (:union (dormant)) (:count))
2
Read. The size of the whole task set, by the status partition.
(-> (current) (:closure :phase-of :depends-on :related) (:enrich))
Read. The transitive neighbourhood of the current task across every edge — the knowledge view.
(-> (active) (:set-status! "completed") (:count))
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.
(-> (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.
(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.
Related
- Tools — the
task_queryandtask_query_writetool surface that runs TQ. - Views — the built-in named views, each with its TQ source text.
- Edges, statuses, and fields — the closed enums TQ predicates and steps validate against.
- Reads, writes, and capabilities — why the
!-gate is one dynamic boundary.