SAIL — Any Type inputs, index(), and the silent empty
The governing fact: SAIL almost never throws on bad data. It returns
{}and renders a confidently wrong UI. There is no error to chase, which is why these cost days rather than minutes.
Rule 1 — never bracket-access an Any Type rule input
ri!rows["scenarioId"] ❌ returns null
index(ri!rows, "scenarioId", {}) ✅ resolves per element, variant-safe
Property access needs the list's element type. An Any Type input arrives as a list of
variants, so there is no element type to resolve against.
This is why an interface works in the rule test panel and breaks when a parent calls it. The
test panel materializes what you type as a real list of dictionaries, so bracket access works. The
parent passes Any Type, so it does not. Do not chase caching or stale saves first.
Rule 2 — never index a field across a mixed-shape list
A query producing both full CDT rows and partial maps can yield a list that mixes shapes.
index(mixedList, "amount", {}) returns {}.
Extract flat scalar columns once, up front. Then work with parallel arrays and never touch a row object again.
Rule 2b — index(text, 1, default) returns the FIRST CHARACTER
index(ri!value, 1, null) with "Mark" → "M" ❌
The text is coerced to its character list. So index() alone is not a safe "first item, or
the item itself" helper.
index(a!flatten({ ri!value }), 1, null) ✅
{ } promotes a scalar to a one-item list · a!flatten() stops a real list from nesting ·
index() then indexes list positions instead of characters. Handles scalar, list, map/CDT, null
and {}.
Text is the only type that shows the coercion. A rule tested only with maps looks correct and breaks on the first string.
Rule 3 — one scalar per slot, for arrays that must stay aligned
index(column, index(where(ids = fv!item), 1, 0), null)
Guarantees exactly one value or null, so chart series stay aligned with categories
structurally, instead of depending on flatten preserving positions.
Rule 4 — type scalars, leave aggregation rows as Any Type
- Type scalar id inputs (Integer, multiple) so a caller cannot pass a text list where an integer list was expected
- Keep aggregation result rows as
Any Type— casting to a CDT or record silently drops query aliases, and a miscast list can yield an empty list
Rule 5 — COUNT(field) on the field you grouped by lies for the null bucket
Grouping and measuring by the same field means every row in the null-category group has
category = null by definition — so that group's count is always 0, no matter how many rows
actually sit in it. Looks exactly like "zero records are missing a category" when the truth may be
the opposite.
Count a field that's never null instead — a primary key, not the field being grouped on.
Scattered traps
| Trap | Consequence |
|---|---|
tointeger() caps at 2,147,483,647 |
Never cast budget dollars |
max({}) |
Errors on an empty list |
a!localVariables |
Evaluates every local regardless of which if branch wins |
| Lists are 1-indexed | Off-by-one against every other language |