Skip to main content

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'] and input.amount are 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.