# RuleBuilder Bundle Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship #438 (validation & polish), #435 (relative-date operators), and #434 (facet-value typeahead) for the visual rule builder on one feature branch.

**Architecture:** Pure-logic changes land in the small `web/src/builder/rules/` modules (types/compile/parse + two new helper modules `validation.ts`, `dateMacro.ts`) under TDD. UI changes to `RuleBuilder.tsx` consume those helpers. #434 adds a disjoint C# read endpoint (Lucene term enumeration) built in a parallel worktree; its combobox is wired last, after the generated API types exist.

**Tech Stack:** React 18 + TypeScript + Vite (vitest for web tests); .NET 10 / C# / MediatR / Lucene.NET (`ISearchIndex`); NUnit + Shouldly + NSubstitute for C# tests.

## Global Constraints

- Work in worktree `feat/rulebuilder-bundle` off `origin/main`. Never commit in `/Users/timothy/ersatztv`. → `process.shared-tree-readonly`
- Backend #434 slice runs in its OWN worktree branched off `feat/rulebuilder-bundle`, merged back by fast-forward/plumbing. All frontend work is one committing agent, sequential (shared `RuleBuilder.tsx`). → `process.one-worktree-one-committing-agent`, `process.foreign-worktree-plumbing-merge`
- The compile↔parse **lossless round-trip contract** (spa-conventions §12) must hold: `parse(compile(g)) ≡ normalizeGroup(g)` for every valid `g`. Extend `roundtrip.test.ts` for every new operator.
- New REST response DTOs live in `ErsatzTV.Core/Api/Search/*ResponseModel.cs` with file-scoped `#nullable enable`. → `api.response-dtos`
- A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` MUST ship regenerated `v1.json` / `v1.d.ts` / `endpoint-index.md` in the same diff. → `release.api-contract-ci-gate`
- Before any push touching `.cs`: BOM-check the touched set (`xxd -p | grep -c ^efbbbf`) and run the format gate under `bash -c`. → `process.bom-format-detection-recipe`
- Independent adversarial review over the whole diff before push (new API endpoint + >~150 lines). → `process.independent-review-rubric`
- Commit trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Worktree hooks: commit with `--no-verify` and run gates manually (worktree husky friction).

**Test commands** (run from the worktree):
- Web unit: `cd web && npx vitest run src/builder/rules/<file>.test.ts`
- Web all rules: `cd web && npx vitest run src/builder/rules`
- C#: `dotnet test ErsatzTV.Application.Tests --filter <Name>` (or the relevant test project)

---

## Task 1: `#438`/`#435` — extend types, then rule/group validation module (`validation.ts`)

**Files:**
- Modify: `web/src/builder/rules/types.ts` (extend first, so validation typechecks)
- Create: `web/src/builder/rules/validation.ts`
- Test: `web/src/builder/rules/validation.test.ts`

- [ ] **Step 0: Extend types.ts** (the new operators/unit are consumed first by validation's tests):

```ts
export type Operator =
  | 'is' | 'isNot' | 'contains' | 'startsWith' // text
  | 'matches' | 'notMatches'                    // fulltext
  | 'eq' | 'gt' | 'lt' | 'between'              // number
  | 'before' | 'after'                          // date (absolute)
  | 'inLast' | 'notInLast';                     // date (relative, #435)

export type DateUnit = 'day' | 'week' | 'month' | 'year';

export interface Rule {
  field: string;
  operator: Operator;
  value: string;
  value2?: string; // upper bound for `between`
  unit?: DateUnit; // unit for `inLast`/`notInLast` (#435)
}
```

and extend the date row of `OPERATORS_BY_TYPE`:

```ts
  date: ['before', 'after', 'between', 'inLast', 'notInLast']
```

**Interfaces:**
- Produces:
  - `ruleError(rule: Rule): string | null` — a human string when the rule is incomplete, else null.
  - `groupHasErrors(group: Group): boolean` — true if any descendant rule has an error.
  - `topGroupAllNegative(group: Group): boolean` — true when every direct child of the *top* group is a negative rule (`isNot`/`notMatches`) (warning predicate, non-blocking).
  - `normalizeGroup(group: Group): Group` — returns a copy with `match` coerced to `'all'` for every group (recursively) that has fewer than 2 children (single-child connective is meaningless and cannot round-trip).

- [ ] **Step 1: Write the failing test**

```ts
// web/src/builder/rules/validation.test.ts
import { describe, expect, it } from 'vitest';
import { groupHasErrors, normalizeGroup, ruleError, topGroupAllNegative } from './validation';
import type { Group, Rule } from './types';

const r = (o: Partial<Rule>): Rule => ({ field: 'title', operator: 'is', value: 'x', ...o });

describe('ruleError', () => {
  it('flags between with a missing upper bound', () => {
    expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '' }))).toBeTruthy();
    expect(ruleError(r({ field: 'minutes', operator: 'between', value: '', value2: '60' }))).toBeTruthy();
    expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '60' }))).toBeNull();
  });
  it('flags empty text values', () => {
    for (const operator of ['is', 'isNot', 'contains', 'startsWith'] as const) {
      expect(ruleError(r({ operator, value: '' }))).toBeTruthy();
      expect(ruleError(r({ operator, value: 'x' }))).toBeNull();
    }
  });
  it('flags relative-date rule with a non-positive or non-integer N', () => {
    expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '', unit: 'day' }))).toBeTruthy();
    expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '0', unit: 'day' }))).toBeTruthy();
    expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }))).toBeNull();
  });
});

describe('groupHasErrors', () => {
  it('walks nested groups', () => {
    const g: Group = { match: 'all', children: [{ match: 'any', children: [r({ value: '' })] }] };
    expect(groupHasErrors(g)).toBe(true);
    expect(groupHasErrors({ match: 'all', children: [r({ value: 'ok' })] })).toBe(false);
  });
});

describe('topGroupAllNegative', () => {
  it('is true only when every top-level child is a negative rule', () => {
    expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'notMatches', field: 'plot' })] })).toBe(true);
    expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'is' })] })).toBe(false);
    expect(topGroupAllNegative({ match: 'all', children: [] })).toBe(false);
  });
});

describe('normalizeGroup', () => {
  it("coerces a single-child group's match to 'all'", () => {
    const g: Group = { match: 'any', children: [r({})] };
    expect(normalizeGroup(g).match).toBe('all');
  });
  it('leaves a 2+ child group match unchanged and recurses', () => {
    const g: Group = { match: 'any', children: [r({}), { match: 'any', children: [r({})] }] };
    const n = normalizeGroup(g);
    expect(n.match).toBe('any');
    expect((n.children[1] as Group).match).toBe('all');
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd web && npx vitest run src/builder/rules/validation.test.ts`
Expected: FAIL — cannot find module `./validation`.

- [ ] **Step 3: Write minimal implementation**

```ts
// web/src/builder/rules/validation.ts
import { isGroup, type Group, type Rule } from './types';

const NEGATIVE = new Set(['isNot', 'notMatches']);
const RELATIVE_DATE = new Set(['inLast', 'notInLast']);

export function ruleError(rule: Rule): string | null {
  if (rule.operator === 'between') {
    if (rule.value.trim() === '' || (rule.value2 ?? '').trim() === '') return 'Both bounds are required.';
    return null;
  }
  if (RELATIVE_DATE.has(rule.operator)) {
    const n = Number(rule.value);
    if (!Number.isInteger(n) || n <= 0) return 'Enter a whole number greater than zero.';
    return null;
  }
  if (rule.value.trim() === '') return 'A value is required.';
  return null;
}

export function groupHasErrors(group: Group): boolean {
  return group.children.some((c) => (isGroup(c) ? groupHasErrors(c) : ruleError(c) !== null));
}

export function topGroupAllNegative(group: Group): boolean {
  const rules = group.children.filter((c): c is Rule => !isGroup(c));
  if (rules.length === 0 || rules.length !== group.children.length) return false;
  return rules.every((r) => NEGATIVE.has(r.operator));
}

export function normalizeGroup(group: Group): Group {
  const children = group.children.map((c) => (isGroup(c) ? normalizeGroup(c) : c));
  return { match: children.length < 2 ? 'all' : group.match, children };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd web && npx vitest run src/builder/rules/validation.test.ts`
Expected: PASS (all cases).

- [ ] **Step 5: Commit**

```bash
git add web/src/builder/rules/types.ts web/src/builder/rules/validation.ts web/src/builder/rules/validation.test.ts
git commit --no-verify -m "feat(438,435): extend rule types; validation + single-child normalization helpers"
```

---

## Task 2: `#435` — dateMacro mapping module

> `types.ts` was already extended in Task 1 Step 0 (operators `inLast`/`notInLast`, `DateUnit`,
> `Rule.unit`, `OPERATORS_BY_TYPE.date`). This task adds only the mapping module.

**Files:**
- Create: `web/src/builder/rules/dateMacro.ts`
- Test: `web/src/builder/rules/dateMacro.test.ts`

**Interfaces:**
- Produces:
  - `dateMacro.ts`:
    - `RELATIVE_FIELD_MAP: Record<'release_date' | 'added_date', 'released' | 'added'>`
    - `compileRelative(rule: Rule): string | null` — e.g. `{release_date, inLast, '7', day}` → `released_inthelast:"7 day"`; null if not a relative-date rule or invalid.
    - `parseRelative(field: string, quoted: string): Rule | null` — recognizes synthetic field + `"<n> <unit>"`, returns a Rule on `release_date`/`added_date`; null otherwise.
    - `RELATIVE_SYNTHETIC = /^(released|added)_(inthelast|notinthelast)$/` (exported for parse.ts field detection).

- [ ] **Step 1: Write the failing test**

```ts
// web/src/builder/rules/dateMacro.test.ts
import { describe, expect, it } from 'vitest';
import { compileRelative, parseRelative } from './dateMacro';
import type { Rule } from './types';

const r = (o: Partial<Rule>): Rule => ({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day', ...o });

describe('compileRelative', () => {
  it('maps release_date/inLast → released_inthelast', () => {
    expect(compileRelative(r({}))).toBe('released_inthelast:"7 day"');
  });
  it('maps added_date/notInLast → added_notinthelast', () => {
    expect(compileRelative(r({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' }))).toBe('added_notinthelast:"3 week"');
  });
  it('returns null for a non-relative rule', () => {
    expect(compileRelative(r({ operator: 'before' }))).toBeNull();
  });
  it('returns null when N is invalid', () => {
    expect(compileRelative(r({ value: '0' }))).toBeNull();
  });
});

describe('parseRelative', () => {
  it('round-trips released_inthelast', () => {
    expect(parseRelative('released_inthelast', '7 day')).toEqual({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' });
  });
  it('round-trips added_notinthelast', () => {
    expect(parseRelative('added_notinthelast', '3 week')).toEqual({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' });
  });
  it('returns null for an unrelated field', () => {
    expect(parseRelative('title', '7 day')).toBeNull();
  });
  it('returns null for a malformed value', () => {
    expect(parseRelative('released_inthelast', 'soon')).toBeNull();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd web && npx vitest run src/builder/rules/dateMacro.test.ts`
Expected: FAIL — cannot find module `./dateMacro`.

- [ ] **Step 3: Write dateMacro.ts**

```ts
// web/src/builder/rules/dateMacro.ts
import type { DateUnit, Rule } from './types';

export const RELATIVE_FIELD_MAP = { release_date: 'released', added_date: 'added' } as const;
type RelField = keyof typeof RELATIVE_FIELD_MAP;

const OP_TO_SUFFIX = { inLast: 'inthelast', notInLast: 'notinthelast' } as const;
const SUFFIX_TO_OP = { inthelast: 'inLast', notinthelast: 'notInLast' } as const;
const CATALOG_FROM_PREFIX = { released: 'release_date', added: 'added_date' } as const;
const UNITS: DateUnit[] = ['day', 'week', 'month', 'year'];

export const RELATIVE_SYNTHETIC = /^(released|added)_(inthelast|notinthelast)$/;

function isValidN(value: string): boolean {
  const n = Number(value);
  return Number.isInteger(n) && n > 0;
}

export function compileRelative(rule: Rule): string | null {
  if (rule.operator !== 'inLast' && rule.operator !== 'notInLast') return null;
  const prefix = RELATIVE_FIELD_MAP[rule.field as RelField];
  if (!prefix) return null;
  if (!isValidN(rule.value) || !rule.unit || !UNITS.includes(rule.unit)) return null;
  return `${prefix}_${OP_TO_SUFFIX[rule.operator]}:"${rule.value} ${rule.unit}"`;
}

export function parseRelative(field: string, quoted: string): Rule | null {
  const m = RELATIVE_SYNTHETIC.exec(field);
  if (!m) return null;
  const [, prefix, suffix] = m;
  const vm = /^(\d+)\s+(day|week|month|year)$/.exec(quoted.trim());
  if (!vm) return null;
  const [, n, unit] = vm;
  if (!isValidN(n)) return null;
  return {
    field: CATALOG_FROM_PREFIX[prefix as 'released' | 'added'],
    operator: SUFFIX_TO_OP[suffix as 'inthelast' | 'notinthelast'],
    value: n,
    unit: unit as DateUnit
  };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd web && npx vitest run src/builder/rules/dateMacro.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add web/src/builder/rules/dateMacro.ts web/src/builder/rules/dateMacro.test.ts
git commit --no-verify -m "feat(435): dateMacro compile/parse mapping for relative-date operators"
```

---

## Task 3: `#438` + `#435` — compile.ts wiring

**Files:**
- Modify: `web/src/builder/rules/compile.ts`
- Modify: `web/src/builder/rules/compile.test.ts`

**Interfaces:**
- Consumes: `compileRelative` (Task 2), `ruleError` (Task 1).
- Produces: `compileRule` emits the relative-date macro for `inLast`/`notInLast`, and returns `''` for a structurally-invalid rule (incomplete `between`, empty text value) so malformed Lucene is never emitted (the empty string is already filtered by `compileGroup`).

- [ ] **Step 1: Write the failing test** — append to `compile.test.ts`:

```ts
import { compileRelative } from './dateMacro'; // ensure no import cycle; if compile imports dateMacro, this is fine

describe('compile — #438 guards + #435 relative dates', () => {
  it('emits the relative-date macro', () => {
    expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }] })).toBe('released_inthelast:"7 day"');
  });
  it('drops an incomplete between instead of emitting field:[v TO ]', () => {
    const out = compile({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30' }] });
    expect(out).not.toContain('TO ]');
    expect(out).toBe(''); // sole invalid child filtered out
  });
  it('drops an empty text value instead of emitting field:* / field:**', () => {
    expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: '' }] })).toBe('');
    expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: '' }] })).toBe('');
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd web && npx vitest run src/builder/rules/compile.test.ts`
Expected: FAIL — relative macro not emitted; incomplete-between emits `minutes:[30 TO ]`.

- [ ] **Step 3: Edit compile.ts**

Add the import and two guards at the top of `compileRule`:

```ts
import { isGroup, type Group, type Rule } from './types';
import { compileRelative } from './dateMacro';
import { ruleError } from './validation';

// ... quote/escapeWild/normalizeDate unchanged ...

function compileRule(rule: Rule): string {
  // #435: relative-date operators map to synthetic query fields.
  const relative = compileRelative(rule);
  if (relative !== null) return relative;

  // #438: never emit malformed Lucene for a structurally-invalid rule; compileGroup filters ''.
  if (ruleError(rule) !== null) return '';

  const f = rule.field;
  const v = rule.value;
  switch (rule.operator) {
    // ... existing cases unchanged ...
    default:
      return '';
  }
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd web && npx vitest run src/builder/rules/compile.test.ts`
Expected: PASS (new + existing cases).

- [ ] **Step 5: Commit**

```bash
git add web/src/builder/rules/compile.ts web/src/builder/rules/compile.test.ts
git commit --no-verify -m "feat(438,435): compile relative-date macros; drop invalid rules instead of malformed Lucene"
```

---

## Task 4: `#435` — parse.ts relative-date recognition + round-trip

**Files:**
- Modify: `web/src/builder/rules/parse.ts`
- Modify: `web/src/builder/rules/parse.test.ts`
- Modify: `web/src/builder/rules/roundtrip.test.ts`

**Interfaces:**
- Consumes: `parseRelative`, `RELATIVE_SYNTHETIC` (Task 2); `normalizeGroup` (Task 1).
- Produces: `parseAtom` recognizes `(released|added)_(inthelast|notinthelast):"..."` and returns the relative Rule; the round-trip test asserts `parse(compile(g)) ≡ normalizeGroup(g)`.

- [ ] **Step 1: Write the failing test** — append to `parse.test.ts`:

```ts
describe('parse — #435 relative dates', () => {
  const ft = { release_date: 'date', added_date: 'date' } as const;
  it('parses released_inthelast back to a relative rule', () => {
    expect(parse('released_inthelast:"7 day"', ft)).toEqual({
      match: 'all',
      children: [{ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }]
    });
  });
  it('parses added_notinthelast', () => {
    expect(parse('added_notinthelast:"3 week"', ft)).toEqual({
      match: 'all',
      children: [{ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' }]
    });
  });
});
```

Note: the synthetic field is NOT in `fieldTypes`, so `parseAtom` must handle it BEFORE the `fieldTypes[field]` lookup (which would reject it).

- [ ] **Step 2: Run test to verify it fails**

Run: `cd web && npx vitest run src/builder/rules/parse.test.ts`
Expected: FAIL — synthetic field returns null (unknown field type).

- [ ] **Step 3: Edit parse.ts**

At the top of `parseAtom`, before the `type` lookup, add relative-date recognition (only the quoted form is valid; `NOT` prefix does not apply):

```ts
import type { FieldType, Group, Operator, Rule } from './types';
import { parseRelative, RELATIVE_SYNTHETIC } from './dateMacro';

function parseAtom(atom: string, fieldTypes: Record<string, FieldType>): Rule | null {
  const trimmed = atom.trim();

  // #435: relative-date synthetic fields are not in the catalog; match them first.
  const relColon = trimmed.indexOf(':');
  if (relColon > 0 && RELATIVE_SYNTHETIC.test(trimmed.slice(0, relColon))) {
    const rawRel = trimmed.slice(relColon + 1);
    if (rawRel.startsWith('"') && rawRel.endsWith('"') && rawRel.length >= 2) {
      return parseRelative(trimmed.slice(0, relColon), rawRel.slice(1, -1));
    }
    return null;
  }

  const negate = trimmed.startsWith('NOT ');
  // ... rest unchanged ...
}
```

- [ ] **Step 4: Update the round-trip test** — in `roundtrip.test.ts`, (a) extend the generator `makeRule` to sometimes emit `date` rules with `inLast`/`notInLast` + a unit, and (b) compare against `normalizeGroup(g)` rather than `g` so single-child groups match. Concretely, import `normalizeGroup` and change the assertion:

```ts
import { normalizeGroup } from './validation';
// ...
// inside the property loop, replace `expect(parse(compile(g), fieldTypes)).toEqual(g)` with:
expect(parse(compile(g), fieldTypes)).toEqual(normalizeGroup(g));
```

For the generator, add a relative-date branch (guard so `value` is a positive integer string and `unit` is set):

```ts
// where a date-field rule is generated:
if (rng() < 0.5) {
  const op = rng() < 0.5 ? 'inLast' : 'notInLast';
  const unit = (['day', 'week', 'month', 'year'] as const)[Math.floor(rng() * 4)];
  return { field: dateField, operator: op, value: String(1 + Math.floor(rng() * 30)), unit };
}
// else fall through to the existing before/after/between generation
```

(Read the actual `roundtrip.test.ts` generator first — match its existing rng/field-selection shape; the above is the required behavior, not a verbatim drop-in.)

- [ ] **Step 5: Run tests to verify they pass**

Run: `cd web && npx vitest run src/builder/rules`
Expected: PASS (parse + roundtrip + all prior).

- [ ] **Step 6: Commit**

```bash
git add web/src/builder/rules/parse.ts web/src/builder/rules/parse.test.ts web/src/builder/rules/roundtrip.test.ts
git commit --no-verify -m "feat(435): parse relative-date macros; round-trip against normalizeGroup"
```

---

## Task 5: `#438` + `#435` — RuleBuilder.tsx UI (validation surface, relative-date inputs, single-child toggle)

**Files:**
- Modify: `web/src/builder/rules/RuleBuilder.tsx`
- Modify: `web/src/screens/CollectionsScreen.tsx` (consumer — disable Save on errors)
- Modify: `web/src/builder/rules/RuleBuilder.test.tsx`

**Interfaces:**
- Consumes: `ruleError`, `groupHasErrors`, `topGroupAllNegative`, `normalizeGroup` (Task 1); `DateUnit` (Task 2).
- Produces: RuleBuilder renders a per-rule error message + an aggregate warning; for `inLast`/`notInLast` it renders a number input + unit `<select>`; single-child groups hide the any/all toggle. Validity is surfaced to the consumer via a new optional prop `onValidityChange?(hasErrors: boolean): void` (or the existing `onChange` contract — read the component first and follow whichever the file already uses).

> **Read `RuleBuilder.tsx` in full before editing** — it is the only file here whose exact JSX shape isn't reproduced in this plan. Match its existing operator-`<select>`, value-`<input>`, and group-toggle patterns. The required behaviors:

- [ ] **Step 1: Relative-date inputs.** When `rule.operator` is `inLast`/`notInLast`, render (in place of the single date input) a numeric `<input>` bound to `rule.value` and a `<select>` bound to `rule.unit` with options `day/week/month/year` (default `day` when the operator is first selected). When switching a date field's operator TO `inLast`/`notInLast`, set `unit: 'day'` if unset; when switching AWAY, clear `unit`.

- [ ] **Step 2: Per-rule error.** Call `ruleError(rule)`; when non-null, render the message inline under the rule row (use the existing error/helper-text styling in the SPA — grep the file/screen for an existing validation message class; do not invent new CSS). This is the #438 block for incomplete `between` / empty text / bad relative-N.

- [ ] **Step 3: Single-child toggle.** Hide the any/all match toggle for any group whose `children.length < 2` (its connective is meaningless and is normalized to `'all'`). Apply `normalizeGroup` to the tree in the builder's `onChange` path so stored state matches what round-trips.

- [ ] **Step 4: All-negative warning.** When `topGroupAllNegative(rootGroup)` is true, render a non-blocking warning near the preview ("This query is entirely negative and will match nothing on its own"). Do NOT disable Save for this.

- [ ] **Step 5: Consumer — disable Save on errors.** In `CollectionsScreen.tsx`, compute `groupHasErrors(group)` and disable the Save/Create button (and skip compile) while true. Read the screen's existing submit-disabled logic and AND this in.

- [ ] **Step 6: Component tests.** Add to `RuleBuilder.test.tsx`: (a) selecting `inLast` shows number + unit inputs; (b) an incomplete `between` shows the error text; (c) a single-child group renders no any/all toggle; (d) an all-negative root shows the warning. Use the existing test-render harness in the file (React Testing Library).

- [ ] **Step 7: Run web tests**

Run: `cd web && npx vitest run src/builder/rules && npx vitest run src/screens/CollectionsScreen`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add web/src/builder/rules/RuleBuilder.tsx web/src/builder/rules/RuleBuilder.test.tsx web/src/screens/CollectionsScreen.tsx
git commit --no-verify -m "feat(438,435): RuleBuilder UI — validation surface, relative-date inputs, single-child toggle"
```

---

## Task 6: `#434` — backend distinct-values endpoint (PARALLEL WORKTREE)

> **Isolation:** run this task in its OWN worktree branched off `feat/rulebuilder-bundle`
> (`git worktree add -b feat/rb-434-backend <path> feat/rulebuilder-bundle`), merged back by
> fast-forward before Task 7. It touches only C# + generated OpenAPI — disjoint from Tasks 1–5.
> **Use csharp-lsp** for symbol lookup. Independent review required (new API endpoint).

**Files:**
- Modify: `ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs` (add method) — *confirm exact path via LSP*
- Modify: `ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs` (implement term enumeration)
- Create: `ErsatzTV.Application/Search/Queries/GetSearchFieldValues.cs` (+ `...Handler.cs`)
- Create: `ErsatzTV.Core/Api/Search/SearchFieldValuesResponseModel.cs`
- Modify: `ErsatzTV/Controllers/Api/SearchController.cs` (add endpoint)
- Test: `ErsatzTV.Application.Tests/Search/GetSearchFieldValuesHandlerTests.cs`
- Regenerate: `ErsatzTV/wwwroot/.../v1.json`, `web/src/api/v1.d.ts`, `docs/openapi/endpoint-index.md` via `./scripts/update-openapi.sh`

**Interfaces:**
- Produces (frontend Task 7 consumes the generated client for): `GET /api/v1/search/fields/{name}/values?q=&limit=` → `{ "values": string[] }`. 404 when `name` is absent from `SearchFieldCatalog` or is not `type == "text"`. `limit` default 50, capped 50; `q` is a case-insensitive prefix filter (empty ⇒ first N terms).

- [ ] **Step 1: Write the failing handler test**

```csharp
// GetSearchFieldValuesHandlerTests.cs (NUnit + Shouldly + NSubstitute)
// - substitute ISearchIndex returning e.g. ["Action","Adventure","Drama"] for field "genre"
// - assert: allow-listed text field "genre" with q="A" returns ["Action","Adventure"] (prefix, case-insensitive)
// - assert: unknown field "nope" → the not-found result (Option.None / Either.Left per the handler's result type)
// - assert: non-text field "minutes" (number) → not-found result
// - assert: limit is clamped to 50
```

Write these as real NUnit `[Test]` methods mirroring an existing `ErsatzTV.Application.Tests/Search/*` test (read one first for the harness/DI shape).

- [ ] **Step 2: Run to verify it fails** — `dotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues` → FAIL (types missing).

- [ ] **Step 3: Implement**
  - `ISearchIndex`: `Task<List<string>> GetFieldValues(string field, string query, int limit);`
  - `LuceneSearchIndex`: open the index reader, enumerate `Terms` for the field (`MultiFields.GetTerms(reader, field)` / `TermsEnum`), filter by case-insensitive prefix on `query`, distinct, take `limit`. (Confirm the Lucene.NET term-enumeration API against the version in `Directory.Packages.props`.)
  - Handler: look the field up in `SearchFieldCatalog` (the static list in `ErsatzTV.Application/Search/SearchFieldCatalog.cs`); if missing or `type != "text"` return the not-found result; else clamp `limit` to `[1,50]`, call `ISearchIndex.GetFieldValues`, wrap in `SearchFieldValuesResponseModel`.
  - DTO: `public record SearchFieldValuesResponseModel(List<string> Values);` with file-scoped `#nullable enable`.
  - Controller: `[HttpGet("fields/{name}/values")]` → `mediator.Send(new GetSearchFieldValues(name, q, limit))`, mapping the not-found result to `NotFound()` (follow the existing `SearchController` result-mapping style).

- [ ] **Step 4: Run to verify pass** — `dotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues` → PASS.

- [ ] **Step 5: Regenerate OpenAPI** — build the app project FIRST, then `./scripts/update-openapi.sh`, then confirm the endpoint appears in `endpoint-index.md` and `v1.d.ts` has `getSearchFieldValues` (or the generated name).

- [ ] **Step 6: Format + BOM gate** (touched `.cs`):
```bash
bash -c 'git diff --name-only feat/rulebuilder-bundle...HEAD -- "*.cs" | while read f; do xxd -p "$f" | grep -q "^efbbbf" && echo "BOM: $f"; done'
bash -c 'dotnet format whitespace . --folder --include $(git diff --name-only feat/rulebuilder-bundle...HEAD -- "*.cs" | tr "\n" " ")'
```

- [ ] **Step 7: Commit** — `git commit --no-verify -m "feat(434): distinct-values search endpoint (text fields, Lucene term enumeration)"`

- [ ] **Step 8: Merge back to feature branch** by fast-forward (per `process.foreign-worktree-plumbing-merge`, land on the branch ref without committing inside a foreign worktree):
```bash
git -C <feat/rulebuilder-bundle worktree> merge --ff-only feat/rb-434-backend
```

---

## Task 7: `#434` — facet-value combobox (frontend, after Task 6 merged)

**Files:**
- Modify: `web/src/api/search.ts` (add `getSearchFieldValues`) — or wherever the generated search client lives
- Modify: `web/src/builder/rules/RuleBuilder.tsx` (combobox for text value inputs)
- Test: `web/src/builder/rules/RuleBuilder.test.tsx`

**Interfaces:**
- Consumes: the generated `GET /api/v1/search/fields/{name}/values` client from Task 6.
- Produces: text-field value inputs (`is`/`isNot`/`contains`/`startsWith`) become an autocomplete combobox; free-text is preserved (any typed value still compiles).

- [ ] **Step 1: API client.** Add `getSearchFieldValues(name: string, q: string, limit = 50): Promise<string[]>` in `web/src/api/search.ts`, mirroring the existing `getSearchFields()` there and reading `.values` from the response.

- [ ] **Step 2: Combobox.** In `RuleBuilder.tsx`, for a `text`-typed field with a text operator, render an autocomplete input: on input change (debounced ~200ms), call `getSearchFieldValues(field, q)` and show a datalist/suggestion dropdown. Selecting a suggestion sets `rule.value`. Typing a value not in the list is still accepted (free-text fallback → preview count remains the safety net). Reuse an existing combobox/datalist pattern in the SPA if one exists (grep for `<datalist` or an autocomplete component); otherwise a native `<datalist>` is the minimal, dependency-free choice.

- [ ] **Step 3: Test.** Add a `RuleBuilder.test.tsx` case: mocking `getSearchFieldValues` to return `["Action","Adventure"]`, typing in a text value input surfaces the suggestions; selecting one updates the rule; a free-typed value not in the list is retained.

- [ ] **Step 4: Run web tests** — `cd web && npx vitest run src/builder/rules` → PASS.

- [ ] **Step 5: Commit** — `git commit --no-verify -m "feat(434): facet-value typeahead combobox for text fields"`

---

## Task 8: Docs — decisions records, spa-conventions §12, api-conventions

**Files:**
- Modify: `docs/decisions.md` (2 new records) + regenerate `docs/decisions/README.md` catalog via `scripts/build_decisions_catalog.py`
- Modify: `docs/spa-conventions.md` §12
- Modify: `docs/api-conventions.md` (endpoint checklist)

- [ ] **Step 1: decisions.md** — append two records (follow the existing 5-field metadata schema `key/status/since/supersedes/superseded-by`; `docs.decision-lifecycle`):
  - `key: api.search-field-values` — "Distinct field-value typeahead is a new `GET /api/v1/search/fields/{name}/values?q=` endpoint enumerating Lucene terms, allow-listed to `text` catalog fields (404 otherwise), capped at 50; enum ships values inline so it needs no endpoint."
  - `key: rulebuilder.relative-date-macros` — "Relative-date operators (`inLast`/`notInLast`) are a frontend-only compile/parse mapping onto the existing `released_inthelast`/`added_inthelast` (+`notinthelast`) `CustomMultiFieldQueryParser` macros (value form `\"<n> day|week|month|year\"`); the `release_date↔released`/`added_date↔added` table is the single seam. No backend change."

- [ ] **Step 2: Regenerate the catalog** — `python3 scripts/build_decisions_catalog.py` (or the documented invocation); verify `decisions_validate.py` passes: `python3 scripts/decisions_validate.py`.

- [ ] **Step 3: spa-conventions.md §12** — add the relative-date operators (#435) and the typeahead-combobox behavior (#434) to the RuleBuilder section; note the single-child-group normalization (#438).

- [ ] **Step 4: api-conventions.md** — add the `search/fields/{name}/values` endpoint to the endpoint checklist/table.

- [ ] **Step 5: Commit** — `git commit --no-verify -m "docs(434,435,438): decisions records, spa-conventions §12, api-conventions"`

---

## Task 9: Whole-diff verification, review, live-E2E, push

- [ ] **Step 1: Full local gate.**
  - `cd web && npx vitest run && npm run build` (or the repo's web test/build scripts)
  - `dotnet build ErsatzTV.sln`
  - `dotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues`
  - BOM + format gate over all touched `.cs` (recipe in Task 6 Step 6).
- [ ] **Step 2: Independent adversarial review** — cold-context review-only agent (different model family if available) over the whole diff. Loop until a clean verdict; re-review the fix commit, not just the initial diff (`release.review-verdict-gate`). Focus: the compile/parse round-trip invariant, the Lucene term-enumeration correctness + the 404 allow-list, and the combobox free-text fallback.
- [ ] **Step 3: Live-E2E** — `scripts/e2e-local.sh` with a fresh config dir; drive the SmartCollection RuleBuilder: build a relative-date rule, save, reload, confirm it round-trips; curl the new `search/fields/genre/values?q=` endpoint and confirm filtered values. (`testing.live-e2e-prepush-timing`.)
- [ ] **Step 4: Push once** (batch all commits), open the PR `fixes #438 #435 #434`, arm the CI monitor on the PR head sha at open (`ci.monitor-armed-at-pr-open`). Post the `Review-verdict:` comment referencing head (`release.review-verdict-gate` / H10).
- [ ] **Step 5:** Tick each issue's `## Done-when` boxes as evidence lands; the merge-consent gate derives consent from state.

---

## Self-Review

**Spec coverage:** #438 (all four cases → Tasks 1,3,5), #435 (operators/mapping/compile/parse/UI → Tasks 2,3,4,5), #434 (endpoint + combobox + OpenAPI → Tasks 6,7), docs (Task 8), review/E2E/push (Task 9). ✔ All spec sections mapped.

**Placeholder scan:** logic-module code is complete and verbatim-runnable; `RuleBuilder.tsx`/C# steps state required behavior + exact signatures with an explicit "read the file first" instruction (their exact JSX/DI shape is not reproduced by design, since it wasn't read into the plan). No TBD/TODO.

**Type consistency:** `Operator` gains `inLast`/`notInLast` (Task 2) used identically in validation (Task 1 test), dateMacro (Task 2), compile (Task 3), parse (Task 4), UI (Task 5). `Rule.unit?: DateUnit` consistent throughout. `normalizeGroup`/`groupHasErrors`/`topGroupAllNegative`/`ruleError` names stable across Tasks 1→5. `GetSearchFieldValues`/`SearchFieldValuesResponseModel`/`getSearchFieldValues` consistent across Tasks 6→7. `RELATIVE_FIELD_MAP`/`RELATIVE_SYNTHETIC` consistent Task 2→4.

**Ordering fix applied:** the `types.ts` extension (operators `inLast`/`notInLast`, `DateUnit`, `Rule.unit`) is Task 1 Step 0, so Task 1's `validation.test.ts` (the first consumer of those types) typechecks and gates green on its own. Task 2 then adds only `dateMacro.ts`. Each task is independently green.
