All notes
SAIL / interfaces

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