168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a `git mv`. WHY: the monolith was a concurrency problem before an aesthetic one. A 3,900-line append target made parallel sessions collide -- PR #605 and PR #614 both hit append-vs-append conflicts during routine rebases, and hand-resolving those inside the corpus is exactly the operation the rationale-rewrite guard exists to police. HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness does not rest on reading it. The parser was taught BOTH formats first, so the body-diff guard parses the old form at the merge-base and the new form at head -- the migration validates itself, no bypass. The proof is a field-level equivalence harness: 168 records before and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing. Reviewers should scrutinise the harness; it is the actual evidence. What measuring caught that reading would not have: - ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each topic file's preamble, mostly the only copy. Source files are kept and stripped, never deleted. They also cannot be filed per-area: topic files hold several areas and 4 of 23 areas span several files. - Archive discovery was a non-recursive glob; after the split it found ZERO archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure. - ~32 live docs point into the corpus BY DATE, which the split dangles. Each stripped file now ends with a generated "Records formerly in this file" index, which also rescues the identical breadcrumbs in old issue comments. - decisions.md's "In this file:" list was 97 same-file anchor bullets that the split makes WRONG, not merely stale. Dropped; the generated index replaces them with links that resolve. The equivalence harness now runs against a checked-in FIXTURE, not the live corpus. The earlier version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the SCRIPT rather than the repo's current state. Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain two directories for one concept. Renaming a key is not a move -- it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work. refs #610
4.0 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| spa.rulebuilder-nesting | 2026-07-25 — Rule-builder group nesting is bounded-arbitrary depth (`MAX_GROUP_DEPTH`), not one level (#436) | active | 2026-07-25 | spa.smartcollection-rule-builder@2026-07-18 | none | 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`). | 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 | `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.