Design: Expressions in ${...} (0.3.0b1)
Status: approved (2026-10-16). Milestone: 0.3.0b1: Expressions. Issue: #56. Checklist: #86.
1. Goal
A flow can count, join, clean, and calculate values inside ${...} without workarounds, and nothing written in a flow can run code. Every flow that works today keeps working unchanged.
- **subject:** ${len(packages)} packages for ${join(ok_ids, ', ')}
- **value:** ${number(row.premium) * 12}
- **name:** ${trim(upper(row.brand))}
- **first:** ${rows[0].request_id}
- **condition:** ${row.status} == Active and ${row.amount} > 0
2. Decisions
| # | Topic | Decision |
|---|---|---|
| E1 | Compatibility | A lookup that works today wins: ${row.First Name} and ${row.Role in Company} stay lookups. The text is read as an expression only when it is not a variable that exists (section 4) |
| E2 | Parsing | Python's own parser (ast.parse, which only builds a tree and runs nothing), then a whitelist of node types and our own evaluator. No eval, exec, or compile of code |
| E3 | Arithmetic | Numbers only. Text that is a plain number ("12", "3.5") counts as that number; other text is an error that suggests number(...) |
| E4 | Comparisons and and / or / not | In conditions only (section 6), where they already live. Inside ${...} there are values, not comparisons, so equality has one meaning (the condition's, case-insensitive) |
| E5 | Missing values | An unknown variable, key, or index is empty (None), as today. Functions treat empty as documented (section 5) |
| E6 | Errors | A malformed expression, an unknown function, or a wrong type fails the step with the expression and the reason, for example ${len(rows, 2)}: len takes 1 value, got 2 |
| E7 | Safety | No attribute access to Python objects: .name reads dictionary keys only, and names starting with _ are refused (this also closes ${x.__class__}, which today reads Python internals). Limits: 500 characters, nesting depth 20 |
| E8 | Validation | kinenix validate parses every ${...} in a flow and reports syntax errors and unknown functions with their line |
3. Syntax
| Form | Example | Result |
|---|---|---|
| Lookup (as today) | ${row.First Name}, ${config.email.to}, ${env.HOME} | The value |
| Index | ${rows[0]}, ${rows[-1]}, ${row['Unit Price (THB)']}, ${rows[i].id} | Item or key; empty when missing |
| Text and numbers | 'abc', "abc", 12, 3.5 | Literals for function arguments |
| Arithmetic | ${count + 1}, ${number(row.price) * 1.07}, ${total / len(rows)}, ${n % 2}, ${-x} | Number; a whole result is shown without .0 |
| Functions | ${len(rows)}, ${join(ids, ', ')} | Section 5 |
| Parentheses | ${(a + b) * 2} | Grouping |
Not allowed, with a clear error: comparisons and and/or/not inside ${...} (use a condition), attribute access to non-dictionaries, method calls (x.upper()), lambdas, comprehensions, f-strings, **, assignment, and any function outside the list.
4. Lookup or expression
For the text inside ${...}:
env.NAME: the environment variable, as today.- Read it as a dotted path, as today. If the first part names an existing variable and every part is found, that is the value.
${row.Role in Company}finds the keyRole in Company. - Otherwise, parse it as an expression. If it parses, evaluate it.
- If it does not parse and contains no expression syntax (parentheses, brackets, operators, or quotes), it is a missing lookup and gives empty, as today (
${row.Missing Column}). If it does contain expression syntax, it is an error (E6).
So a dictionary key that happens to look like an expression is still read as a key, and nothing that resolves today changes its value. The only change: text inside ${...} that resolved to empty and is a valid expression now has a value, for example ${len(items)} (before: empty).
5. Functions
| Function | Result | Empty input |
|---|---|---|
len(x) | Number of characters, items, or keys | 0 |
join(list, sep) | Items as text, joined by sep (default ", ") | "" |
number(x) | The first number in the text, without thousands separators and units: "15,434.75 บาท/ปี" gives 15434.75, "(1,200)" gives -1200 | empty; text with no number is an error |
trim(x), lower(x), upper(x) | Text without surrounding spaces, lower case, upper case | "" |
replace(x, old, new) | Text with every old replaced | "" |
default(x, fallback) | x, or fallback when x is empty or "" | fallback |
round(x, digits) | Rounded number (digits defaults to 0) | empty |
part(x, sep, n) | Piece n of the text split at sep, counted from 0; a negative n counts from the end: part('25-10-2019', '-', 1) gives 10, part('INV-2026-00123', '-', -1) gives 00123 | "", also when there is no piece n |
substring(x, start, length) | length characters from position start (counted from 0), or to the end when length is left out; positions past the end give what is there | "" |
Extra functions are added only when a real flow needs them; the list is in one place (engine/expressions.py), with its tests and docs/flow_markdown_spec.md.
6. Conditions: and, or, not
- **condition:** ${row.status} == Active and ${row.amount} > 0
- **condition:** ${brand} is empty or ${model} is empty
- **condition:** not ${row.note} contains skip
andbinds tighter thanor; there are no parentheses at this level.notapplies to one comparison.- A condition is split at
and/oroutside${...}and quotes, and only when every part is a condition (it has an operator, or is a single${...}). So${company} == Johnson and Johnsonstays one comparison with the right sideJohnson and Johnson, as today. Quoting is the safe form:${company} == 'Johnson and Johnson'. - Each part keeps today's comparison rules (case-insensitive text, numeric
<and>,in,contains,is empty).
7. Implementation
kinenix-core/kinenix/engine/expressions.py: parse withast.parse(text, mode="eval"), cache the tree per text, walk it with an evaluator that accepts only these nodes:Expression,Name,Constant(text and numbers),Attribute(dictionary key),Subscript,BinOp(+ - * / %),UnaryOp(-,+),Call(whitelisted name, positional arguments). Every other node is refused.engine/evaluator.py: the order of section 4; the dotted lookup stops reading attributes of non-dictionaries and names starting with_.engine/conditions.py:and/or/notas in section 6, on top of the existing splitter.engine/validation.py: expression syntax and function names inkinenix validate.
8. Tests
- Each function and operator, with normal, empty, and bad input; numbers from Thai and English text.
- Compatibility: every example flow and every existing test runs unchanged; keys with spaces, parentheses, and the word
in. - Safety:
__class__,__import__, method calls, lambdas, comprehensions,**, very long and deeply nested input are refused; the evaluator module contains noeval,exec, orcompilecall. - Fuzz: thousands of random and malformed expressions built from the grammar and from random characters; each ends within a time limit with a value or an expression error, never another exception.
9. Out of scope
User-defined functions, loops, or lambdas (never, for safety); date arithmetic (date.calc); regular expressions (1.1.0 if a real flow needs them); comparisons inside ${...} (conditions do this).