--- key: spa.rulebuilder-nesting title: 2026-07-25 — Rule-builder group nesting is bounded-arbitrary depth (`MAX_GROUP_DEPTH`), not one level (#436) status: active since: '2026-07-25' supersedes: spa.smartcollection-rule-builder@2026-07-18 superseded-by: none rule: 'The visual rule builder''s `Group` nests recursively to a single shared cap, `MAX_GROUP_DEPTH` (`types.ts`, currently 5, root group = depth 0) — read by the UI''s "Add group" gate, `parse.ts` and the round-trip property-test generator alike; everything else about the builder is unchanged from #176 (compile-only closed Lucene subset over the stored query string, no stored rule AST, field vocabulary from `GET /api/v1/search/fields`).' signals: 'rule builder nesting depth, MAX_GROUP_DEPTH, nested groups, Kodi one-level model reversed, recursive Group, compile parenthesization, parse recursion, closed subset, SmartCollection query, ChannelBuilder smart query · paths: `web/src/builder/rules/types.ts`, `parse.ts`, `compile.ts`, `RuleBuilder.tsx`, `roundtrip.test.ts` · issues: #436, #176, #437, #438' mechanics: '`spa-conventions.md` §12; superseded predecessor in `docs/decisions/archive/spa.md`' --- #176 shipped the builder with a deliberate one-level "Kodi" nesting cap (`type:movie AND (genre:Horror OR genre:Thriller)`) and scoped recursive nesting out as YAGNI, "revisit only if a real query needs it". #436 is that revisit. **What is reversed is only the cap** — the compile-only closed-subset stance, the no-stored-AST stance and the catalog-driven field vocabulary all carry forward verbatim from the archived record. **Bounded, not unbounded.** The `Group` type is structurally recursive (it always was — `children: Array`), and `compile.ts` already parenthesized recursively, so the honest change is a *policy* one: how deep may a tree be? Truly unbounded nesting buys nothing a 5-deep tree can't express while making the UI illegible (each level insets 12px inside a fixed-width dialog) and admitting pathological hand-written input into the compiler. `MAX_GROUP_DEPTH = 5` is a judgment call sized to "deeper than any smart-playlist query anyone has actually asked for", not a technical limit. **One constant, three consumers — never re-hardcode a depth.** `MAX_GROUP_DEPTH` lives in `types.ts` and is read by (a) `RuleBuilder.tsx`'s "Add group" gate (`depth < MAX_GROUP_DEPTH`, replacing `depth === 0`), (b) `parse.ts`, which returns `null` for input nested deeper than the cap, and (c) `roundtrip.test.ts`'s tree generator. A second hardcoded depth anywhere would silently desynchronize the UI from the parser and break the round-trip invariant the whole module rests on. **`parse` stays the exact inverse of `compile`, including at the cap.** Anything deeper than `MAX_GROUP_DEPTH` is *out of subset* and yields `null` — the existing, well-defined degradation to raw-text editing — rather than a truncated or best-effort tree, which is the same rule that already covers fuzzy queries, boosts and mixed AND/OR at one level. Generalizing the recursion also tightened the sub-group detection: a part is a sub-group only when its leading `(` is the one closed by its trailing `)` (quote- and escape-aware), so adjacent groups like `(a)x(b)` can no longer be mistaken for one wrapped group. **The property test asserts depth coverage, not just the invariant.** `roundtrip.test.ts` generates 500 trees to the full cap and additionally asserts the generated corpus actually *reached* `MAX_GROUP_DEPTH` — a generator that silently stopped nesting would otherwise keep passing every round-trip assertion while testing nothing new (the "fan-out needs count-guards" lesson applied to a generator). Explicit depth-3 cases live in `compile.test.ts` / `parse.test.ts` / `validation.test.ts` so a regression is diagnosable without decoding a random seed. **No backend change.** Nesting depth is entirely a client-side model concern; the stored query string is still plain Lucene, so nothing about `/api/v1`, the search index or the MCP surface moves.