Skip to content

Expression operations

Every operation the expression engine defines, organized by the type it operates on. This page is a verified snapshot of the engine’s catalog; the live source of truth is always:

Terminal window
cai expr ops --type <text|number|boolean|date|list|file|custom> --json

When this page and the command disagree, the command wins. How expressions are authored — the write loop, binding roots, empty-value semantics — lives in Expressions.

Operations per type

TypeStatic opsPlus
text22
number19
boolean8
date12
list18one dynamic each_<fieldId> per inner-type field
custom.X4one getter per schema field
dataRecord.custom.X4one getter per runtime schema field (adds id/createdAt/updatedAt)
file46 fixed field getters
output.X3data + error getters

62 distinct operations exist across all types.

display strings are what the visual builder shows; the ids are what the stored AST contains.

OperationParametersResult typeNotes
capitalized_wordsnonetextSplits on a single space, uppercases the first character of each chunk, rejoins. Does not lowercase the rest, so "mcDONALD farms""McDONALD Farms". nullnull.
lowercasenonetextnullnull.
uppercasenonetextnullnull.
trimmednonetextnullnull.
slugifynonetextNFKD-normalizes, strips combining marks, trims, lowercases, collapses every non-[a-z0-9] run to a single -, strips leading/trailing -. nullnull.
number_of_charactersnonenumbervalue?.length ?? 0 — a null text yields 0, not null.
format_json_encodenonetextJSON.stringify(value). On text this adds surrounding quotes — it is an escape-for-embedding op, not a formatter.
converted_to_numbernonenumberHardened: blank/non-numeric strings resolve to null, never NaN (the old Number(x) leaked NaN into arithmetic and produced output like "member for NaN days"). Numeric strings still coerce. Non-finite numbers → null.
converted_to_dateproperties: format_type, custom_format, timezonedateDelegates to parseTextToDate. Never returns an Invalid Date and never throws. Default (no format_type, or auto) = strict ISO 8601. A supplied pattern is a hint: pattern first, strict ISO second, else null. epoch_seconds/epoch_millis require /^-?\d+$/ and a safe integer. timezone (default UTC) only affects offset-less input; an embedded offset/Z wins.
converted_to_listnonelist.textAlways wraps: [value]. A null input becomes [null] — a 1-item list containing null, not an empty list.
defaulting_toargs: scalar or TextExpressiontext“Empty” for text = null/undefined or a whitespace-only string, so " " IS replaced. is_slidable.
equalsargs: scalar or TextExpressionbooleanCompares String(a) === String(b)both sides are String()-coerced, so a null value compares as the literal "null".
not_equalsargsbooleanSame String() coercion.
containsargsbooleanString(a).includes(String(b)).
not_containsargsbooleanNegation of the above; display is doesn't contain.
appendargstextA null previous value is treated as '' and a null argument as '', so this never produces "null".
split_byargslist.textnull previous → []. A null/missing separator splits on "" (every character).
find_replaceproperties: find, replace (both TextExpression)textLiteral, not regex — implemented as split(find).join(replace), so it replaces every occurrence. An empty/missing find returns the input unchanged. A missing replace deletes the matches. null previous → null.
truncated_toargs: number literal or expressiontextslice(0, n) — keeps the first n characters (inclusive, unlike list_until). Number(arg) || 0, so a non-numeric or zero argument yields "". null previous → null.
is_emptynonebooleanvalue == null || value === ''. Whitespace-only text is not empty here (differs from defaulting_to).
is_not_emptynonebooleanExact inverse of the above.
summarize_with_aiargs: prompt TextExpression; properties: ai_model (default gpt-4o-mini)textEmpty/null input short-circuits to null without an API call. Model ids starting with gemini throw. Runs on the system OpenAI connection and writes a third-party-LLM usage record; metering failures are logged, never fatal.

OperationParametersResult typeNotes
floornonenumberGuarded by toFiniteNumber — non-numeric input → null.
ceilingnonenumberSame guard.
absolutenonenumberSame guard.
rounded_toargs: decimal placesnumberPlaces are clamped to 0–15 and truncated to an integer. Uses a 15-significant-digit normalization plus an epsilon nudge so 1.005 rounds as a human expects. is_slidable.
converted_to_textnonetextnull/undefined'' (empty string, not null).
converted_to_listnonelist.numberWraps as [value]; null[null].
defaulting_toargsnumber“Empty” for number = null/undefined or a non-finite number. 0 is NOT empty, so ?? 0 never overwrites a real 0. An empty string is also not platform-empty here and passes through unchanged.
plusargsnumberAll four arithmetic ops go through applyArithmetic: if either operand is missing/non-numeric the result is null, and a non-finite result (e.g. divide by zero) is also null. Numeric strings still coerce (integration payloads often carry numbers as text); blank strings do not.
minusargsnumberAs above.
timesargsnumberAs above.
divided_byargsnumberAs above — division by zero yields null, not Infinity.
greater_thanargsboolean⚠️ Raw JS > with no numeric guard, unlike the arithmetic ops. null/"" coerce to 0, so an empty value is < 5 (true) and not > 5. This is not fail-closed — contrast with the date comparisons.
greater_than_or_equal_toargsbooleanSame raw-comparison caveat.
less_thanargsbooleanSame raw-comparison caveat.
less_than_or_equal_toargsbooleanSame raw-comparison caveat.
equalsargsbooleanStrict ===, no String() coercion (unlike text equals). A numeric string "5" does not equal the number 5.
not_equalsargsbooleanStrict !==.
is_emptynonebooleanvalue == null || value === ''. 0 is not empty.
is_not_emptynonebooleanInverse.

OperationParametersResult typeNotes
is_truenonebooleanStrict value === true. A truthy non-boolean (e.g. 1, "yes") is not true.
is_falsenonebooleanStrict value === false. Note both are false for nullis_true and is_false are not complements.
format_booleanproperties: formatting_for_true, formatting_for_false (TextExpressions)textTernary in text form. Anything that is not exactly true takes the false branch, including null. is_slidable.
format_boolean_numberproperties: formatting_for_true (default 1), formatting_for_false (default 0)numberSame false-branch-catches-null behaviour. Each branch may be a literal number or a TextExpression.
converted_to_textnonetextnull/undefined''; otherwise "true"/"false".
format_json_encodenonetextJSON.stringify(value).
converted_to_listnonelist.booleanWraps as [value].
defaulting_toargsboolean“Empty” for boolean = null/undefined only. false is a real value and is never replaced.

Boolean has no is_empty/is_not_empty in its own table — the JS layer still accepts .isEmpty() on any type and emits the op generically (see the JS section).


OperationParametersResult typeNotes
formatted_asproperties: format_type (default MM/DD/YY), custom_format, timezonetextformat_type: 'iso_date'.toISOString(); 'custom'custom_format; anything else is used directly as a moment pattern, falling back to YYYY-MM-DD HH:mm:ss. Timezone defaults to UTC. Invalid input → null.
converted_to_listnonelist.dateWraps as [value].
plus_secondsargs: amountdateAll six plus_* ops share addToDate: if the date is unparseable or the amount is non-numeric the result is null. Negative amounts are supported (subtract).
plus_minutesargsdateAs above.
plus_hoursargsdateAs above.
plus_daysargsdateAs above.
plus_monthsargsdateAs above — moment calendar arithmetic, so month-end clamps (Jan 31 + 1 month = Feb 28/29).
plus_yearsargsdateAs above.
greater_thanargsbooleanFail-closed: if either side fails to parse into a valid moment the result is false. Displayed as is after in the JS/op tables.
less_thanargsbooleanFail-closed the same way. Displayed as is before. Note there is no >=/<= and no equals on dates.
is_emptynonebooleanTrue for null, undefined, '', or a Date whose time is NaN.
is_not_emptynonebooleanInverse.

Date value tolerance (toValidMoment): accepts a real Date, an ISO 8601 string parsed strictly, or a finite epoch number. Anything else resolves to null rather than an Invalid Date — date-typed values routinely cross JSON transport as ISO strings.


list.<inner> is generated per inner type. path below = the inner type id.

OperationParametersResult typeNotes
each_<fieldId>properties: selectedPropertylist.<fieldType>Dynamic — one op is generated per field of the inner type, only when the inner type is structured. Display is :each item's <Field>. List-valued fields auto-flatten one level (list.X stays list.X). Non-array input → []. Custom-typed fields stay typed so the chain keeps resolving.
countnonenumbervalue?.length ?? 0.
sumnonenumber⚠️ Only registered when the inner type is exactly number. Non-array → 0. Uses raw + with no coercion, so a list of numeric strings would concatenate.
averagenonenumberSame number-only restriction. Empty or non-array list → null (not 0).
first_itemnoneinner typevalue?.[0] ?? null.
last_itemnoneinner typevalue?.[length - 1] ?? null.
item_numberargs: indexinner type1-based: reads value[n - 1]. Display :item #.
list_fromargs: indexlist.<inner>slice(n - 1) — 1-based and inclusive of item n.
list_untilargs: indexlist.<inner>⚠️ slice(0, n - 1)EXCLUSIVE. :items until 3 returns the first two items. Asymmetric with list_from, and asymmetric with text truncated_to (which is inclusive).
filteredproperties: constraints, sort_field, descending, ignore_empty_constraintslist.<inner>Two passes, both applied: simple field constraints narrow the list first, then advanced constraints (constraint_type: 'empty', i.e. a boolean expression evaluated per item with the item injected) run on the narrowed result. A constraint whose field no longer exists on the schema is logged and skipped. With ignore_empty_constraints: true a constraint whose value evaluates to nil is skipped instead of matching nothing (opt-in — the default is fail-closed). Optional sort_field sorts the result.
list_containsargsbooleanNon-array → false. Primitives use includes; objects are compared by JSON.stringify equality.
sortedproperties: sort_field, descending (default false)list.<inner>Non-array → []. Sorts a copy. Nil values always sort last regardless of direction. Comparison is type-aware via the field’s returnCType (date → epoch, number → numeric, boolean → 0/1), else localeCompare on the stringified values.
format_as_textproperties: content (TextExpression, item injected), delimiter (TextExpression)textEmpty list → ''. Default separator is '' (no delimiter), unlike join_with.
join_withargs: separatortextNon-array → ''. Default separator is ', ' when the argument is nil. Every item is String(item)-coerced, so objects become [object Object].
stringifynonetextJSON.stringify, falling back to String(value) if that throws. null/undefined''.
is_emptynoneboolean⚠️ value?.length === 0 — for a null/undefined list this evaluates to undefined (falsy), not true.
is_not_emptynonebooleanvalue?.length > 0 — also undefined for a nil list.
merge_withargs: another listlist.<inner>Concatenates. A nil previous value is treated as []; a missing argument returns the previous list unchanged. No de-duplication, no type check on the second list.
find_with_aiargs: criteria TextExpression; properties: model (default gpt-4o-mini)inner typeEmpty list short-circuits to null without an API call. gemini* model ids throw. The whole list is JSON-serialized into the prompt; the model returns a 0-based index, and an out-of-range or -1 answer yields null. Writes a usage record.

Filter constraint types (ConstraintType, used by filtered): empty (advanced/boolean-expression), equals, not_equals, contains, not_contains, is_empty, is_not_empty, is_in, is_not_in, greater_than, less_than, greater_than_or_equal_to, less_than_or_equal_to, contains_item, not_contains_item.

Constraint gotchas from applyConstraints: item values are read as item[field.id] ?? item[field.display] (ids and displays never collide, so either keying works); ordered comparisons use raw JS operators with no coercion guard; is_in/is_not_in require the constraint value to actually be an array — is_not_in against a non-array keeps every item.


Custom (custom.X) and data records (dataRecord.custom.X)

Section titled “Custom (custom.X) and data records (dataRecord.custom.X)”

Both providers expose the same four static ops plus one getter per schema field.

OperationParametersResult typeNotes
<fieldId>properties: selectedPropertythe field’s declared typeDynamic — one op per field, category dataFields. Values are read as item[primary] ?? item[secondary] ?? null, where the primary key is the field id for integration/output/flow types and the field display for regular workflow-data types. On dataRecord, custom- and record-typed fields are resolved through the workflow-data store rather than read inline.
stringifynonetextJSON.stringify with a String(value) fallback; null/undefined''.
converted_to_listnonelist.custom.X / list.dataRecord.custom.XWraps as [value].
is_emptynonebooleanlodash isNil — only null/undefined count. An empty object {} is not empty.
is_not_emptynonebooleanInverse.

Differences between the two shapes:

  • custom.X is the pure user schema. Its fields come from getWorkflowDataUserSchemaFields (or the raw dataType.fields for integration types custom.__*).
  • dataRecord.custom.X is the persisted-record presentation: the same schema flattened together with the record fields (getWorkflowDataRuntimeSchemaFields adds id/createdAt/updatedAt). Its display is "<Type> record".
  • Integration custom types (custom.__*) cannot be data records — the provider throws.

file is a fixed built-in type: a Controller-hosted file with metadata.

OperationParametersResult typeNotes
id / fileName / fileType / mimeType / urlproperties: selectedPropertytextPlain property reads, ?? null.
sizeBytesproperties: selectedPropertynumberPlain property read.
stringifynonetextAs above.
converted_to_listnonelist.fileWraps as [value].
is_emptynonebooleanisNil.
is_not_emptynonebooleanInverse.

Node results are wrapped as { data: <innerType>, error: text }.

OperationParametersResult typeNotes
dataproperties: selectedPropertythe inner typevalue?.data ?? null.
errorproperties: selectedPropertytextvalue?.error ?? null.
converted_to_listnonelist.output.XWraps as [value].
is_empty / is_not_emptynonebooleanisNil-based.

Wrappers are transparent to the op resolver: the compiler’s unwrapForOps peels node.<id>#data, dataRecord., and output. prefixes (up to 6 levels) before looking up which ops apply, so ops on the inner type work straight through. The one exception is ??, which is rejected directly on an output. wrapper — you must take .data first.


Users and agents write a restricted JavaScript-like syntax that is parsed with acorn and compiled to the op AST above. The grammar:

Optional const x = ...; declarations plus ONE final expression. Values: string/number/boolean literals, template literals, and chains starting from flow.<name> / inputs.<name> / process / item / now / workflowData("custom.x"), extended with per-type methods, field access, comparisons (chain on the LEFT), ternary, and ! on booleans. Arrow functions only inside list .filter()/.some()/.find()/.every(), list .map(x => x.field), or list .formatAsText(x => ...).

The CLI entry points are cai expr context, cai expr validate, cai expr set, cai expr ops, and cai expr decompile.

These are the only identifiers that start a chain. Names verified against compileNode’s Identifier case and the DataSource enum.

JS rootCompiles toTypeNotes
flow.<name>data_source / flow_input (flowInputId)the input’s declared typeNamespace, dot access only. Resolution tries exact name, then camelCase→snake_case, then display-name match, then a case/separator-insensitive squash. In strictBindings mode the last two fallbacks are errors with a suggested rewrite.
inputs.<name>data_source / node_input (inputId)the upstream node’s output typeSame four-level resolution. This is also how trigger nodes are referenced — there is no trigger root; the compiler only tracks nodeLocations[nodeId] === 'trigger' to tailor error hints. Binding names are camelCased from the display name, de-duplicated with a numeric suffix.
processdata_source / process_outputprocessOutputType, else output.unknownThe current process’s result. Display Process result.
iteminjected_valuethe injected source’s typeThe loop / per-item value. Errors with “item (injected value) is not available here” when the context has no injection source. There is no this-item / thisItem spelling.
now (alias currentDate)data_source / datedateCurrent date/time. The DataSource enum member is named dateTime but its wire value is date.
isLiveVersion (alias live)data_source / is_live_versionbooleanDisplay Is live version.
workflowData("custom.x")data_source / workflow_data_searchlist.custom.xA function call, not a namespace. Requires a string literal type argument; stores it as properties.workflowDataType. Always returns a list.
true / false literalsdata_source / boolean_true | boolean_falsebooleanBoolean literals compile to fixed data sources, not to inline values.

const bindings declared earlier in the program are also valid chain starts; they are inlined (structurally cloned) at each use.

  • A template literal compiles to a parts value: literal chunks stay as strings, each ${...} becomes an embedded expression block, and the whole thing is typed text.
  • + is overloaded: if either side is textual it produces the same parts concatenation; if both sides are number literals it constant-folds; otherwise it looks up the type’s binary op (plus, etc.).
  • Comparisons want the chain on the left. A literal-on-the-left comparison (5 < flow.x) is auto-flipped to the mirrored chain-left spelling (><, >=<=, ===/!== unchanged).
  • String() is rejected as unnecessary; JSON.stringify/JSON.parse are rejected with a pointer to .stringify() / .jsonEncode().
JS constructOp idOn typeArg form
.toLowerCase()lowercasetextnone
.toUpperCase()uppercasetextnone
.capitalizedWords() / .capitalize()capitalized_wordstextnone
.trim()trimmedtextnone
.slugify()slugifytextnone
.length (property)number_of_characterstextnone
.toNumber() / Number(<text chain>)converted_to_numbertextnone
.toDate([format][, tz])converted_to_datetextstring literals; "iso_date"/"auto" store no format_type; "epoch_seconds"/"epoch_millis" are special and reject a tz argument; presets pass through; anything else becomes custom + custom_format
.toList()converted_to_listtext/number/boolean/datenone
.jsonEncode()format_json_encodetext, booleannone
x ?? <literal>defaulting_totext, number, booleanFallback must be a literal of the matching type. Rejected on date, list, custom, dataRecord.*, and output.*
===equalstext, numberscalar
!==not_equalstext, numberscalar
.includes(v)contains (text) / list_contains (list)text, listscalar
.notIncludes(v)not_containstextscalar
.isEmpty()is_emptyanynone — emitted generically even for types with no table entry
.isNotEmpty()is_not_emptyanynone — same
.summarizeWithAI([prompt][, model])summarize_with_aitextprompt defaults to “Summarize the given text concisely, capturing the key points.”; model key is ai_model
.split(sep)split_bytextscalar
.concat(v) / .append(v)append (text) / merge_with (list)text, listscalar (text) / chain (list)
.truncatedTo(n) / .slice(0, n)truncated_totextnumber literal or expression; only the slice(0, n) shape is accepted
.replace(find, repl) / .replaceAll(...)find_replacetexttwo args → properties.find / properties.replace
.floor() / Math.floor(x)floornumbernone
.ceiling() / Math.ceil(x)ceilingnumbernone
Math.abs(x)absolutenumbernone (global form only)
.roundedTo(n)rounded_tonumbernumber literal, at most one; defaults to 0
.toText()converted_to_textnumber, booleannone
+ - * /plus, minus, times, divided_bynumberscalar
> >= < <=greater_than, greater_than_or_equal_to, less_than, less_than_or_equal_tonumberscalar
> / < on a date chaingreater_than / less_thandatescalar (is after / is before)
.isTrue() / .isFalse()is_true / is_falsebooleannone
!<boolean chain>is_falsebooleannone
cond ? a : bformat_boolean_number when both branches are number literals, else format_booleanbooleanbranches become formatting_for_true / formatting_for_false
.format(fmt[, tz])formatted_asdatestring literal; a known preset sets format_type, otherwise format_type: 'custom' + custom_format
.plusSeconds(n).plusYears(n)plus_secondsplus_yearsdatenumber literal only; negative literals are supported (now.plusDays(-1))
.length (property) on a listcountlistnone
.sum() / .average()sum / averagelistnone — only meaningful on list.number
.first() / .last()first_item / last_itemlistnone
.item(n)item_numberlistnumber literal, 1-based
list[0]first_itemlistindex must be a number literal
list[n] (n > 0)item_number with n + 1listcompiles with a warning — JS is 0-based, the engine is 1-based
.itemsFrom(n) / .itemsUntil(n)list_from / list_untillistnumber literal, 1-based
.filter(pred[, opts])filteredlistarrow predicate → constraints; optional { ignoreEmptyConstraints: true }
.some(pred)filtered + is_not_emptylistdesugar
.find(pred)filtered + first_itemlistdesugar
.every(pred)single condition → negated filtered + is_empty; multi-condition AND (or an ordered OR) → filtered + count compared with the unfiltered countlistdesugar
.map(x => x.field)each_<fieldId>listsingle field pick only (x.field or x["exact_id"])
.formatAsText(x => \…`[, sep])`format_as_textlistarrow body → properties.content with the item injected; second arg → properties.delimiter
.join(sep)join_withlistscalar
.sortBy(field[, "asc"|"desc"]) / .sorted(...)sortedliststring literals; the field is validated against the inner schema
.findWithAI(criteria[, model])find_with_ailistmodel key is model (a known engine inconsistency vs ai_model)
.stringify()stringifycustom, list, filenone
.<fieldName>the field getter opcustom / schema-backedcamelCase of the field id; use ["raw_id"] for ids that do not camelCase cleanly
.data / .errordata / erroroutput wrapperfield access delegates through the wrapper; an exact schema field id named data wins over the built-in accessor, but a display-name match does not shadow it

Predicate grammar (inside .filter() / .some() / .find() / .every())

Section titled “Predicate grammar (inside .filter() / .some() / .find() / .every())”

Single-parameter arrow with an expression body (no { } block). Each condition starts with <param>.<field> or <param>["exact_field_id"] followed by ===, !==, >, <, >=, <=, .includes(v), !....includes(v), .isEmpty(), or .isNotEmpty(). Methods and arithmetic on the field side are rejected — compute on the value side instead.

Constraints from the code that shape what compiles:

  • Multi-condition AND groups join only with &&. || joins single conditions only and cannot mix with &&.
  • At most 4 || branches.
  • At most one ordered comparison (>, <, >=, <=) per || group — for a null/NaN/non-numeric field the engine makes both the comparison and its negated guard false, which would silently drop a later matching branch.
  • || cannot be combined with { ignoreEmptyConstraints: true } (an empty-skipped guard would break branch disjointness and duplicate items).
  • || returns branch-major order rather than source order — warned for .filter(), and warned separately for .find() where branch order decides which match wins.
  • || over workflowData() runs the search once per branch; .every() over workflowData() runs it once per branch plus once more for the unfiltered count. Each run is an independent snapshot.

new / Date objects, array literals, object literals, optional chaining ?., &&/|| outside list predicates, loops, assignments, destructuring, await, spread, tagged templates, IIFEs, computed calls obj[m](), user-defined functions, regex (.match()), .startsWith()/.endsWith(), .toFixed(), .forEach()/.reduce(), parseInt/parseFloat, JSON.stringify/JSON.parse, and dynamic list indexes (indexes must be number literals — this is an engine limitation, not just a DSL one). Non-const declarations are rejected, and the last statement must be the result expression.


The complete assignability rule set:

  1. Identical ids are assignable.
  2. List-ness must match on both sides — list.X is never assignable to X or vice versa.
  3. Identical base types (after stripping the list. prefix) are assignable.
  4. The only widening: a dataRecord. source is assignable to a non-dataRecord expected type when the inner types match. So dataRecord.custom.leadcustom.lead is allowed, and list.dataRecord.custom.leadlist.custom.lead is allowed.

The reverse is explicitly not allowed: custom.lead is not assignable to dataRecord.custom.lead. A plain custom shape has no persisted id/createdAt/updatedAt, so it cannot stand in for a record.

There are no other implicit conversions. Text→number, number→text, text→date and so on all require an explicit op (converted_to_number, converted_to_text, converted_to_date).

What text interpolation does per value type

Section titled “What text interpolation does per value type”

In text templates, each entry is a list of items; items are concatenated with '', and entries are joined with '\n' (that is how multi-line templates are represented).

Item / evaluated valueResult
A literal string itemPassed through unchanged.
A null item (not value) in the entries arrayThrows Null expression found.
Evaluated value null or undefinedRenders as '' — an empty binding silently vanishes rather than printing "null".
A plain object (non-array, non-Date, non-null)Throws Object value found. This is a deliberate tripwire, not a bug: the comment says it exists “to catch issues in time”, and each case that turns out to be legitimate gets added to the skip conditions. Interpolate a field or .stringify() instead.
An arrayAllowed — falls through to .toString(), i.e. comma-joined with no spaces.
A DateAllowed — falls through to .toString(), i.e. the JS date string, not ISO. Use .format() for a controlled rendering.
A number / boolean.toString()5"5", true"true".

The result of the whole interpolation is always typed text.

In processExpression / outputExpression / processCondition mode the engine does not interpolate:

  • A raw primitive prop value that is not a TextExpression is coerced up into { type: 'TextExpression', entries: [[String(value)]] }, with a warning logged.
  • A scalar TextExpression literal is coerced by JS type and typed accordingly: numbernumber, booleanboolean, everything else → text.
  • A non-TextExpression in these modes throws a shape error naming the expression path and the expected shape.

When the compiled expression is written back to a prop, acceptedType decides the strictness:

  • text — anything is wrapped as a TextExpression.
  • onlyWhen — the expression must infer to boolean, otherwise it is an error.
  • Any other concrete type — a literal or template whose inferred type differs is an error; a chain whose inferred type differs is a warning (the engine’s own coercion may still be correct at runtime).
  • unknown — always a warning when the inferred type is anything other than unknown.