---
key: spa.smartcollection-rule-builder
title: '2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)'
status: superseded
since: '2026-07-18'
supersedes: none
superseded-by: spa.rulebuilder-nesting@2026-07-25
rule: (superseded) The SmartCollection visual rule builder compiles to/from a closed subset of the Lucene grammar over the existing stored query string — no new AST, one level of group nesting.
signals: 'SmartCollection, rule builder, Lucene compile/parse · paths: `web/src/builder/rules/`, `compile.ts`, `parse.ts`, `roundtrip.test.ts` · issues: #176, #69'
mechanics: superseded by `spa.rulebuilder-nesting` (ersatztv#436) — the compile-only closed-subset and field-catalog stances carry forward there; only the one-level nesting cap was reversed. See `docs/decisions.md` → that record; `spa-conventions.md` §12
---

The SmartCollection create/edit dialog gained a visual rule builder (`web/src/builder/rules/`)
alongside the existing raw-Lucene textarea. **The SmartCollection still stores a plain Lucene query
string — no new stored rule AST, no schema change.** The builder compiles its in-memory rule tree
into a **closed subset** of the Lucene grammar (`compile.ts`) and parses exactly that subset back out
(`parse.ts`, the exact inverse — returns `null`, not a best-effort guess, for anything outside the
subset); escaping is total, so any builder-authored query round-trips losslessly, proven by a 500-tree
property test (`roundtrip.test.ts`, including Lucene special characters). Opening an existing
SmartCollection tries the parse first and falls back to raw-text mode on `null` (fuzzy queries,
boosts, mixed AND/OR at one nesting level, or nesting deeper than one level).

**Why compile-only over persisting an authoritative rule AST**: an AST would still need a
Lucene→rules parser to open every *pre-existing* free-text query — including every query the
Auto-Tune feature (#69) generates — so the AST would buy almost nothing (it still can't represent
arbitrary hand-written Lucene) while costing a dual-provider EF migration and a second source of
truth to keep in sync with the Lucene grammar. Compile-only keeps the query string as the single
source of truth and treats the builder as a structured *editor* over it, not a new storage model.

**One-level-nesting "Kodi" model.** `types.ts` defines a top `Group` (`match: all|any`) over `Rule`s
and/or **one level** of sub-`Group`s — enough to express `type:movie AND (genre:Horror OR
genre:Thriller)`, which covers the smart-playlist patterns Kodi-style rule builders are known for.
Arbitrary/recursive nesting was scoped out as YAGNI; revisit only if a real query needs it.

**Field vocabulary comes from a new catalog endpoint, not a hardcoded list.** `GET
/api/v1/search/fields` (read-only, MCP-introspectable; see `api-conventions.md`) returns the curated,
typed, labeled field set derived from `LuceneSearchIndex` — name/label/type/group/values — and is the
single source of truth the builder's field pickers (`fieldCatalog.ts`'s `useSearchFields` hook) and
operator/value-input choices are driven from, so the builder's vocabulary can't drift from what the
index actually supports.

**Deferred as separate follow-up issues** (explicitly out of scope for #176): facet-value typeahead
for value inputs, relative-date operators, nesting deeper than one level, and inline adoption of
`RuleBuilder` by ChannelBuilder / Auto-Tune (it was built reusable for exactly that reuse — see
`spa-conventions.md` §12).
