Paths & Filters
This page explains how a rule picks one value, one row, or several rows out of the data it was given.
The path grammar
String literals
A string literal may be written with single quotes or double quotes — 'abc' and "abc" are
the same literal. The two forms are fully equivalent: a double-quoted literal is accepted
anywhere a single-quoted one is (CONCAT("a", "b") is exactly as legal as CONCAT('a', 'b')),
generally, across the whole grammar — this is not a step-reference-only special case, and a
reader should not infer a narrower rule just because double quotes are only shown below inside a
step reference. Both forms take the same backslash escapes and neither spans a newline.
The double-quoted form exists because the ratified step-reference spelling (see the reserved
steps namespace, described further down in this section, ratified at plan 02-08 Task 0
option-a) — steps["checkout/items/validate-total"].amount — requires one: a step path contains forward
slashes, and quoting it is how the grammar tells a literal path segment apart from everything else
inside the brackets. Adding a second, position-dependent string-literal concept just for that one
spelling was the worse option, so the lexer instead keeps one string-literal concept, accepted
in both spellings, everywhere. This was purely additive — every pre-existing single-quoted literal
is untouched.
Every access form resolves through one path resolver, shared by a context path (input.amount), a
step reference, and a bare field name inside a filter predicate:
- Property access:
input.amount,steps["…"].amount— a plain dotted field name. - Bracket property access:
input['total amount'],input['@currencyID'],input['cbc:ID']— a property whose name is not a plain identifier (it contains a space, starts with an attribute-prefix character, or carries a namespace prefix) is addressed by writing the name as a string literal inside brackets instead of after a dot. The bracket must hold a literal, not a computed string —input[CONCAT('a', 'b')]is still refused, because the addressed property must stay statically knowable. When the literal's value happens to be a valid identifier, the bracket form addresses the exact same property the dot form would (input['amount']andinput.amountare one path, not two) — this is a second spelling of the SAME thing, not a different capability. This is the form every read of an XML transform step's output needs, because that conversion emits namespace-prefixed element keys (cbc:ID) and attribute-prefixed keys (@currencyID) by design; the canonical nested read looks like this, and the[0]on every element is the visible cost of every element child always being an array:input['cac:Line'][0]['cbc:ID'][0] - Index access:
positions[0]— the bracket is classified as an index when its content's declared type is a number. - Slice access:
positions[1:3]— either bound may be omitted, meaning the collection's own start or end. - Wildcard access:
positions[*]— maps over every element. - Filter access:
positions[price > 100]— the bracket is classified as a filter when its content's declared type is boolean; a bare field name inside the predicate (price) resolves against the element currently under test, and is only meaningful inside a predicate.
A bracket whose content is neither a literal property name, a position, nor a boolean condition —
including a bare data path such as positions[input.i] — is a coded path syntax error. The
literal-property reading above is decided from the bracket content's NODE KIND (a string literal,
decided before any type is consulted); index-versus-filter is still decided from the bracket
content's declared return type, read with no knowledge of the declared input schema — bracket
classification runs in the same context-free pass this whole page describes, before any per-run
type inference is consulted, so it can never see what a declared input schema says a bare path's
type is. A bare data path such as positions[input.i] therefore always types as any here,
regardless of what the input schema declares i to be, and is refused for that reason — this is a
permanent property of how classification works, not a temporary gap Phase 3's inference closes: a
data-driven index still needs an expression whose type is decidable without a declared schema —
a function call with a statically-known return type (positions[LENGTH(other) - 1]), a literal, or
arithmetic over one of those — never a bare, untyped data path on its own.
A string literal inside steps[...] is a different thing from bracket property access above,
even though the spelling looks identical: steps["checkout/items/validate-total"] is the reserved
step-reference form (described further down in this section), never a property name — the two
meanings are distinguished only by the root the bracket follows (steps versus anything else).
Out-of-range access is strict, never a clamp. positions[99] on a three-element array
fails, and so does a slice reaching past the end — a clamp would silently return a different
collection than the author asked for. Because access is strict, an index or slice bound may be an
arbitrary expression, not only a literal (positions[0:LENGTH(positions) - 1]), so an author can
always compute a bound from the collection's own length rather than relying on a clamp. A bound that
is not a whole number, or whose magnitude is too large to name a position, is a distinct coded error
from out-of-range — "index 3.5 is out of range" would send an author to look at the wrong thing.
Negative indices count from the end — positions[-1] is the last element, and a
negative slice bound behaves correspondingly. On an empty array this is still out of range, per the
strict rule above.
A filter or wildcard over a non-array is a save-time error where the declared schema proves the
target is not an array, and yields an empty result at runtime where the type is only known
dynamically — the same "catch it statically, stay survivable dynamically" shape equality
uses. A filter always returns an array, including for exactly one match, and an empty
result is an empty array, never an error — "no positions over a hundred" is a normal business
answer. An author who wants a single value uses GET or FIND_BY instead, both documented on the
Collections page.
The reserved steps namespace is unshadowable: an input field literally named steps
never captures it, and stays reachable as input.steps. The concrete spelling —
steps["checkout/items/validate-total"].amount — is a one-way door: it appears inside persisted
ruleset documents, so changing it later would be a migration of stored customer data, not a
refactor. Whether a referenced step actually exists, and what type it yields, is resolved by Phase
3's graph binding — this phase fixes only the grammar, the AST node, and the type-checker hook.