tiki/.doc/doki/doc/ruki/custom-fields-reference.md
2026-04-15 18:32:55 -04:00

9.9 KiB

Custom Fields Reference

Table of contents

Overview

Custom fields extend tiki's built-in field catalog with user-defined fields declared in workflow.yaml. This reference covers the precise rules for how custom fields are loaded, validated, persisted, and queried — the behavioral contract behind the Custom Fields user guide.

Registration and loading

Custom field definitions are loaded from all workflow.yaml files across the three-tier search path (user config, project config, working directory). Files that define fields: but no views: still contribute field definitions.

Definitions from multiple files are merged by name:

  • identical redefinition (same name, same type, same enum values in same order): silently accepted
  • conflicting redefinition (same name, different type or values): fatal error naming both source files

After merging, fields are sorted by name for deterministic ordering and registered into the field catalog alongside built-in fields. Once registered, custom fields are available for ruki parsing, validation, and execution.

Registration happens during bootstrap before any task or template loading occurs.

Naming constraints

Field names must:

  • match the ruki identifier pattern (letters, digits, underscores; must start with a letter)
  • not collide with ruki reserved keywords (select, update, where, and, or, not, in, is, empty, order, by, asc, desc, set, create, delete, limit, etc.)
  • not collide with built-in field names, case-insensitively (title, status, priority, tags, dependsOn, etc.)
  • not be true or false (reserved boolean literals)

Collision checks are case-insensitive: Status and STATUS both collide with the built-in status.

Type coercion rules

When custom field values are read from frontmatter YAML, they are coerced to the expected type:

Field type Accepted YAML values Coercion behavior
text string pass-through
enum string case-insensitive match against allowed values; stored in canonical casing
integer integer or decimal number integer pass-through; decimal accepted only if it represents a whole number (e.g. 3.03, but 1.5 → error)
boolean true / false pass-through
datetime timestamp or date string native YAML timestamps pass through; strings parsed as RFC3339, with fallback to YYYY-MM-DD
stringList YAML list of strings each element must be a string
taskIdList YAML list of strings each element coerced to uppercase, whitespace trimmed, empty entries dropped

Enum domain isolation

Each enum field maintains its own independent set of allowed values. Two enum fields never share a domain, even if their values happen to overlap.

This isolation is enforced at three levels:

  • assignment: set severity = category is rejected even if both are enum fields
  • comparison: where severity = category is rejected (comparing different enum domains)
  • in-expression: string literals in an in list are validated against the specific enum field's allowed values

Enum comparison is case-insensitive: category = "Backend" matches a stored "backend".

Validation rules

Custom fields follow the same validation pipeline as built-in fields:

  • type compatibility: assignments and comparisons are type-checked (e.g. you cannot assign a string to an integer field, or compare an enum field with an integer literal)
  • enum value validation: string literals assigned to or compared against an enum field must be in that field's allowed values
  • ordering: custom fields of orderable types (text, integer, boolean, datetime, enum) can appear in order by clauses; list types (stringList, taskIdList) are not orderable

Persistence and round-trip behavior

Custom fields are stored in task file frontmatter alongside built-in fields. When a task is saved:

  • custom fields appear after built-in fields, sorted alphabetically by name
  • values that look ambiguous in YAML (e.g. a text field containing "true", "42", or "2026-05-15") are quoted to prevent YAML type coercion from corrupting them on reload

A save-then-load cycle preserves custom field values exactly. This holds as long as:

  • the field definitions in workflow.yaml have not changed between save and load
  • enum values use canonical casing (enforced automatically by coercion)
  • timestamps round-trip through RFC3339 format

Schema evolution and stale data

When workflow.yaml changes — fields renamed, enum values added or removed, field types changed — existing task files may contain values that no longer match the current schema. tiki handles this gracefully:

Removed fields

If a frontmatter key no longer matches any registered custom field, it is preserved as an unknown field. Unknown fields survive load-save round-trips: they are written back to the file exactly as found. This allows manual cleanup or re-registration without data loss.

Stale enum values

If a task file contains an enum value that is no longer in the field's allowed values list (e.g. severity: critical after critical was removed from the enum), the value is demoted to an unknown field with a warning. The task still loads and remains visible in views. The stale value is preserved in the file for repair.

Type mismatches

If a value cannot be coerced to the field's current type (e.g. a text value "not_a_number" in a field that was changed to integer), the same demotion-to-unknown behavior applies: the task loads, the value is preserved, a warning is logged.

General principle

tiki reads leniently and writes strictly. On load, unrecognized or incompatible values are preserved rather than rejected. On save, values are validated against the current schema.

Template defaults

Custom fields in new.md templates follow the same coercion and validation rules as task files. If a template contains a value that cannot be coerced (e.g. a type mismatch), the invalid field is dropped with a warning and the template otherwise loads normally.

Template custom field values are copied into new tasks created via create statements or the new-task UI flow.

Query behavior

Custom fields behave identically to built-in fields in ruki queries:

  • usable in where, order by, set, create, and select field lists
  • support is empty / is not empty checks
  • support in / not in for list membership
  • list-type fields support + (append) and - (remove) operations
  • quantifiers (any ... where, all ... where) work on custom taskIdList fields

Unset list fields

Unset custom list fields (stringList, taskIdList) behave the same as empty built-in list fields (tags, dependsOn). They return an empty list, not an absent value. This means:

  • "x" in labels evaluates to false (not an error)
  • labels + ["new"] produces ["new"] (not an error)
  • labels is empty evaluates to true

Missing-field semantics

When a custom field has never been set on a task, ruki returns the typed zero value:

Field type Zero value
text "" (empty string)
integer 0
boolean false
datetime zero time
enum "" (empty string)
stringList [] (empty list)
taskIdList [] (empty list)

Setting a field to empty removes it entirely, making it indistinguishable from a field that was never set.

Distinguishability by type

Enum fields preserve the missing-vs-set distinction: "" is not a valid enum member, so category is empty only matches tasks where the field was never set (or was cleared). A task with category = "frontend" is never empty.

Boolean and integer fields do not preserve this distinction: false and 0 are both the zero value and the empty value. If you need to tell "never set" from "explicitly false" or "explicitly zero", use an enum field with named values (e.g. yes / no) instead.

Worked examples

Suppose blocked is a custom boolean field:

Query never set false true
select where blocked = true no no yes
select where blocked = false yes yes no
select where blocked is empty yes yes no
select where blocked is not empty no no yes

Suppose category is a custom enum field with values [frontend, backend, infra]:

Query never set "frontend"
select where category = "frontend" no yes
select where category is empty yes no
select where category is not empty no yes