Semantics
July 11, 2026 · View on GitHub
Table of contents
- Overview
- Statement semantics
- Trigger semantics
- Qualifier scope
- Plugin target qualifiers
- Time trigger semantics
- Condition and expression semantics
Overview
This page explains how ruki statements, triggers, conditions, and expressions behave.
Statement semantics
select
selectwithoutwheremeans a statement with no condition node.select where ...validates the condition and its contained expressions.select ... order by <field> [asc|desc], ...specifies result ordering.- A subquery form
selectorselect where ...can appear only insidecount(...),choose(...), orexists(...). Subqueries do not supportorder by.
order by
- Each field must exist in the schema and be an orderable type.
- Orderable types:
int,date,timestamp,duration,string, enum,id,ref. - Non-orderable types:
list<string>,list<ref>,recurrence,bool. - Default direction is ascending. Use
descfor descending. - Duplicate fields are rejected.
- Only bare field names are allowed —
old.andnew.qualifiers are not valid inorder by.
limit
- Must be a positive integer.
- Applied after filtering and sorting, before any pipe action.
- If the limit exceeds the result count, all results are returned (no error).
create
createis a list of assignments.- At least one assignment is required.
- The resulting tiki must have a non-empty
title. This can come from an explicittitle=...assignment or from the tiki template. - Duplicate assignments to the same field are rejected.
- Every assigned field must exist in the injected schema.
id,createdBy,createdAt,updatedAt, andfilepathare immutable and cannot be assigned.
update
updatehas two parts: awherecondition and asetassignment list.- At least one assignment in
setis required. - The
whereclause and every right-hand side expression are validated. - Duplicate assignments inside
setare rejected. id,createdBy,createdAt,updatedAt, andfilepathare immutable and cannot be assigned.
delete
deletealways requires awherecondition.- The
wherecondition is validated exactly likeselect where ....
Trigger semantics
Triggers have the shape:
<timing> <event> [where <condition>] <deny-or-action>
Rules:
beforetriggers must havedeny.beforetriggers must not have an action orrun(...).aftertriggers must not havedeny.aftertriggers must have either a CRUD action orrun(...).- trigger CRUD actions may be
create,update, ordelete, but notselect
Examples:
before update where new.status = "done" and dependsOn any status != "done" deny "open dependencies"
after create where new.priority <= "medium-high" and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
At runtime, triggers execute in a pipeline: before-triggers run as validators before persistence, the mutation is persisted, then after-triggers run as hooks. For the full execution model, cascade behavior, configuration, and runtime details, see Triggers.
Qualifier scope
Qualifier rules depend on the event:
createtriggers:new.is allowed,old.is notdeletetriggers:old.is allowed,new.is notupdatetriggers: bothold.andnew.are allowed- standalone statements: neither
old.nornew.is allowed outer.is allowed only inside a subquery bodytarget.andtargets.are allowed only in plugin runtime (see Plugin target qualifiers); they are rejected in event triggers and time triggers
Examples:
before create where new.type = "story" and new.description is empty deny "stories must have a description"
before delete where old.priority <= "medium-high" deny "cannot delete high priority tikis"
before update where old.status = "inProgress" and new.status = "done" deny "review required"
select where not exists(select where outer.id in dependsOn)
Important special case:
- inside a quantifier body such as
dependsOn any ...,old.andnew.are disabled again - use bare fields inside the quantifier body, not
old.ornew. outer.remains available if that quantifier body is itself inside a subquery
Example:
before update where dependsOn any status = "done" deny "blocked"
Correlated subqueries:
- inside
count(select ...),choose(select ...), andexists(select ...), bare fields refer to the subquery's candidate task outer.fieldrefers to the immediate parent row that invoked the subquery- nested subqueries rebind
outer.to their direct parent subquery row, not to the top-level row - in trigger subqueries,
old.andnew.still refer to the trigger snapshots whileouter.refers to the guard or action target row
Examples:
select where not exists(select where outer.id in dependsOn)
select where count(select where assignee = outer.assignee and status = "inProgress") > 1
before update where exists(select where id = outer.id and assignee = new.assignee) deny "blocked"
Plugin target qualifiers
Plugin runtime adds two qualifiers that resolve against the currently selected tasks:
target.<field>— the field value of the single selected task. Uses the same exactly-one-selection contract asid(): zero selections raiseMissingSelectedTaskIDError, more than one raisesAmbiguousSelectedTaskIDError.targets.<field>— a deduped projection of the named field across all selected tasks, preserving first-seen order (by selection order, then by field value order within a list field). Zero selected tasks produce an empty list, matchingids().
Rules:
- both qualifiers are only valid in plugin runtime; standalone CLI, event triggers, and time triggers reject them at semantic validation time.
targets.<field>flattens list-valued fields.targets.tagsandtargets.dependsOnproduce a flat list of unique values rather than a list of lists.targets.<field>does not automatically exclude the selected task IDs from the result. Subtract them explicitly if needed (for exampletargets.dependsOn - targets.id).- projection types stay within existing list types:
id, ref fields, andlist<ref>fields project tolist<ref>.string, enum, andlist<string>fields project tolist<string>.- scalar types without a list representation (
int,date,timestamp,duration,bool,recurrence) are rejected — usetarget.<field>for those or convert explicitly.
- a selected task ID that does not resolve to a known task raises a clear runtime error.
- lane
filter:expressions rejecttarget.andtargets.at parse time. Lane filters run on every render with no selection payload, so those qualifiers cannot resolve. Use them in plugin actions or laneaction:expressions instead (lane actions receive the moved task as a single selection).
Examples:
-- act on the same task the user is focused on
update where id = target.id set status = "done"
-- expand work to the selected task's blockers, de-duped across the selection
select where id in targets.dependsOn
-- copy the selected task's assignee to other items in the board
update where type = "bug" set assignee = target.assignee
-- filter the board down to statuses the selection covers
select where status in targets.status
Plugin actions that reference target.<field> or targets.<field> auto-infer the same selection
requirements as id() and ids(): target. requires exactly one selected task, and targets.
requires at least one. The executor preflights the single-selection contract before evaluating
the statement, so the error surfaces even when target.<field> sits behind a short-circuited
condition.
Time trigger semantics
Time triggers have the shape:
every <duration> <statement>
Rules:
- the interval must be a positive duration (e.g.
1hour,2day,1week) - the inner statement must be
create,update, ordelete— notselect run()is not allowed inside a time triggerold.andnew.qualifiers are not allowed — there is no mutation context for a periodic operationtarget.andtargets.are not allowed — time triggers have no plugin selection context- bare field references in the inner statement resolve against the tasks being matched, exactly as in standalone statements
Examples:
every 1hour update where status = "inProgress" and updatedAt < now() - 7day set status="inbox"
every 1day delete where status = "done" and updatedAt < now() - 30day
every 2week create title="sprint review" status="ready" priority="medium"
Condition and expression semantics
Conditions:
- a bare expression condition must evaluate to a boolean value
- comparisons validate both operand types before checking operator legality
is emptyandis not emptyare allowed on every supported typeinandnot inrequire a collection on the right sideanyandallrequirelist<ref>on the left side- list equality (
=/!=) is set-like: order and duplicate count are ignored
Absent fields
Every field on a tiki is either present (the frontmatter declares the key) or absent (the key is
not in the frontmatter at all). Comparisons and quantifiers have well-defined semantics for absent
fields — the engine does not hard-error on absence, it just resolves to a defined value. Schema-known
fields (status, type, priority, points, tags, dependsOn, due, recurrence, assignee) and
custom user-defined fields share the same presence-aware behavior.
Rules that follow from presence:
- Equality with a concrete value is asymmetric on absent fields:
where <field> = <value>is false andwhere <field> != <value>is true. For an integer field namedestimate,where estimate = 0does not match tikis that never declared it; only tikis whose frontmatter literally wroteestimate: 0match.where estimate != 0matches both "declared estimates other than 0" and "no estimate declared". - Equality with
emptytreats absent as empty:where <field> = emptyis true for absent fields, andwhere <field> != emptyis false. This is the only equality form where absent and present-zero behave the same. - Ordering comparisons (
<,>,<=,>=) on an absent field hard-error — there is no defined ordering for "no value". Guard ordering predicates withhas(<field>)when the input could be sparse. where <field> is emptytreats absent as empty and evaluates true;where <field> is not emptyevaluates false. Sois emptycannot distinguish "absent" from "present-but-zero" — usehas(<field>)when you need that distinction.where <list> = []is an equality comparison against a concrete value, not againstempty, so it follows the asymmetric rule: it does not match absent lists. Match absent lists withis empty,= empty, ornot has(<field>)instead.anyreturns false over an absent list (no elements to satisfy the predicate).allreturns true over an absent list (vacuous truth) — the same result as a present-but-empty list.inreturns false when its left-hand side is absent (where assignee in ["bob"]does not match tikis withoutassignee).not inreturns true in the same case (where assignee not in ["bob"]does match tikis withoutassignee). When you want "value-is-set and not in this list", combinehas(<field>)withnot in.where has(<field>)is the only predicate that distinguishes "present with zero value" from "absent".has()also accepts qualified references —has(new.assignee)is the canonical "new tiki declares an assignee" predicate in triggers.order by <field>requires every row to have a defined value. Absent values raise an error; gate the query withhas(<field>)or sort on a different field. (Some sparse-friendly views may add documented null-ordering in the future.)- Projections (
select id, title, priority) render absent scalar fields as empty cells in the table formatter and as JSONnullin the JSON formatter. List fields always render as[]so downstream scripts can iterate without a null check; if the caller needs to distinguish "absent list" from "present-empty list", they should gate the select withhas(<field>)in the where clause. - Arithmetic contexts (
set dependsOn = dependsOn + "ABC123",set tags = tags - ["a"]) treat an absent list operand as an empty list because the expression constructs a new list. The assignment then writes the resulting list to frontmatter — this is the canonical "first-time add" idiom. The list is written even when the result is empty, sohas(<field>)becomes true after the assignment regardless of length.
Setting any field on a tiki simply writes that key into its frontmatter:
update where id = "ABC123" set status = "ready" adds status: ready. There is no separate "promotion"
step or hidden classifier — fields are added like ordinary map entries. Sparse serialization is
preserved: set estimate = 0 writes exactly estimate: 0, not a full schema.
Removing a field is explicit. The canonical clear is set <field> = empty: assigning the empty literal
deletes the key from frontmatter so subsequent loads see it as absent. List arithmetic does not delete
a list field even when the result is empty — set tags = tags - ["only-tag"] writes an empty list
(tags: []) and has(tags) remains true. Use set tags = empty to actually remove the key.
Examples:
select where true
select where blocked
select where not blocked
select where title -- invalid: title is string-typed
select where estimate -- invalid when estimate is int-typed
Expressions:
- field references resolve through the injected schema
- qualified references use the same field catalog, then apply qualifier-policy checks
- list literals must be homogeneous
emptyis a context-sensitive zero value, resolved by surrounding type checks- subqueries are only legal as the argument to
count(...),choose(...), orexists(...)
Binary + and - are semantic rather than purely numeric:
- string-like
+yieldsstring int + intandint - intyieldintlist<string> +/- string-or-list<string>yieldslist<string>with set semanticslist<ref> +/- id-ref-compatible valuesyieldslist<ref>with set semanticsdate + durationyieldsdatedate - durationyieldsdatedate - dateyieldsdurationtimestamp + durationyieldstimestamptimestamp - durationyieldstimestamptimestamp - timestampyieldsduration
For the detailed type rules and built-ins, see Types And Values and Operators And Built-ins.
Pipe actions on select
select statements may include an optional pipe suffix:
select <fields> where <condition> [order by ...] [limit N] | run(<command>)
select <fields> where <condition> [order by ...] [limit N] | clipboard()
| run(...) — shell execution
Evaluation model:
- The
selectruns first, producing zero or more rows. - For each row, the
run()command is executed with positional arguments ($1,$2, etc.) substituted from the selected fields in left-to-right order. - Each command execution has a 30-second timeout.
- Command failures are non-fatal — remaining rows still execute.
- Stdout and stderr are fire-and-forget (not captured or returned).
Rules:
- Explicit field names are required —
select *and bareselectare rejected when used with a pipe. - The command expression must be a string literal or string-typed expression, but field references are not allowed in the command string itself.
- Positional arguments
$1,$2, etc. are substituted by the runtime before each command execution.
Example:
select id, title where status = "done" | run("myscript \$1 \$2")
For a task with id = "ABC123" and title = "Fix bug", the command becomes:
myscript "ABC123" "Fix bug"
Pipe | run(...) on select is distinct from trigger run() actions. See Triggers for the difference.
| clipboard() — copy to clipboard
Evaluation model:
- The
selectruns first, producing zero or more rows. - The selected field values are written to the system clipboard.
- Fields within a row are tab-separated; rows are newline-separated.
- Uses
atotto/clipboardinternally — works on macOS, Linux (requiresxcliporxsel), and Windows.
Rules:
- Explicit field names are required — same restriction as
| run(...). clipboard()takes no arguments — the grammar enforces empty parentheses.
Examples:
select id where id = id() | clipboard()
select id, title where status = "done" | clipboard()
For a single task with id = "ABC123" and title = "Fix bug", the clipboard receives:
ABC123 Fix bug