---
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<Rule | Group>`), 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.
