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.
  • 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 types as any — a bare, not-yet-typed data path such as positions[input.i] — is a coded path syntax error in this phase: the index-versus-filter classification is entirely static, decided from the bracket content's declared return type, and a bare data path has no declared type until Phase 3's inference exists. A data-driven index is not expressible until then.

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 endpositions[-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.