The existing POST `/studies` validator at [`backend/app/api/v1/studies.py:238`](../../backend/app/api/v1/studies.py#L238) enforces `judgment_list.query_set_id == body.query_set_id` (cross-set anti-enu
feat_query_inline_crud --> feat_chat_agent
feat_studies_ui --> feat_chat_agent
feat_study_lifecycle --> feat_chat_agent
+ feat_study_target_judgment_mismatch_guard --> feat_chat_agent
infra_adapter_elastic --> feat_chat_agent
infra_ci_smoke_makeup --> feat_chat_agent
infra_dashboard_regen_pre_commit_conflict --> feat_chat_agent
diff --git a/docs/01_architecture/api-conventions.md b/docs/01_architecture/api-conventions.md
index be7625b1..ea74f35b 100644
--- a/docs/01_architecture/api-conventions.md
+++ b/docs/01_architecture/api-conventions.md
@@ -75,6 +75,8 @@ The studies endpoint surfaces two template-mismatch codes (added by `chore_creat
|---|---|---|
| `SEARCH_SPACE_UNKNOWN_PARAM` | 400 | A key in `search_space.params` is not declared by the selected template. Message format: `"Param '{name}' is not declared by template '{template_name}'. Declared params: [...]."` `retryable: false`. |
| `SEARCH_SPACE_MISSING_DECLARED_PARAM` | 400 | A key in the template's `declared_params` is missing from the submitted `search_space.params`. Message format: `"Template '{template_name}' declares param '{name}' but it is missing from the search space. Add it or remove from the template."` `retryable: false`. |
+| `JUDGMENT_CLUSTER_MISMATCH` | 422 | `judgment_list.cluster_id` does not equal the study's `cluster_id` on `POST /api/v1/studies` (added by `feat_study_target_judgment_mismatch_guard`, 2026-05-21). `retryable: false`. Fires BEFORE `JUDGMENT_TARGET_MISMATCH` — cluster mismatch is the broader failure because doc IDs are physically scoped to a cluster (same target name on two clusters still produces zero overlap). Recovery: pick a judgment list created against the study's cluster, or change the study's cluster. |
+| `JUDGMENT_TARGET_MISMATCH` | 422 | `judgment_list.target` does not equal the study's `target` on `POST /api/v1/studies` (added by `feat_study_target_judgment_mismatch_guard`, 2026-05-21). `retryable: false`. Fires AFTER the cluster check. Recovery: pick a judgment list authored against the study's target, or change the study's target. Catches the literal study2 incident — judgments authored on `e2e-target` paired with a study against `docs-articles` would otherwise burn the entire trial budget scoring 0.0 on every (params, query) pair. |
The clusters endpoint surfaces an ACL-restriction code on the targets sub-resource (added by `feat_create_study_target_autocomplete`, 2026-05-20):
diff --git a/docs/02_product/planned_features/feat_study_target_judgment_mismatch_guard/feature_spec.md b/docs/02_product/planned_features/feat_study_target_judgment_mismatch_guard/feature_spec.md
new file mode 100644
index 00000000..3c97e80d
--- /dev/null
+++ b/docs/02_product/planned_features/feat_study_target_judgment_mismatch_guard/feature_spec.md
@@ -0,0 +1,545 @@
+# Feature Specification — feat_study_target_judgment_mismatch_guard
+
+**Date:** 2026-05-21
+**Status:** Draft
+**Owners:** RelyLoop maintainers
+**Related docs:**
+- [idea.md](idea.md)
+- [api-conventions.md](../../../01_architecture/api-conventions.md)
+- [data-model.md](../../../01_architecture/data-model.md)
+- Sibling: [feat_study_preflight_overlap_probe](../feat_study_preflight_overlap_probe/idea.md)
+- Sibling: [feat_orchestrator_zero_streak_abort](../feat_orchestrator_zero_streak_abort/idea.md)
+- Upstream: [chore_e2e_test_rows_isolation](../chore_e2e_test_rows_isolation/idea.md)
+
+**Depends on:** None. Feature is purely additive (no migration, no new external dep). Builds on the existing studies POST validator at [`backend/app/api/v1/studies.py:240-247`](../../../../backend/app/api/v1/studies.py#L240-L247) and the existing judgment-list listing filter pattern at [`backend/app/db/repo/judgment_list.py:58-140`](../../../../backend/app/db/repo/judgment_list.py#L58-L140).
+
+---
+
+## 1) Purpose
+
+- **Problem:** Operators can create a study whose `study.target` (the index/collection the study queries) does not match the `judgment_list.target` (the index the judgments were authored against). When these mismatch, the judgment doc IDs cannot intersect search results from the study's target — pytrec_eval scores 0 on every (params, query) pair by construction. The orchestrator burns the entire trial budget producing `best_metric=0.0`. Real incident: study `019e4be6-207e-7c32-9889-f6c3003f57c2` ran 1000 trials in 4.5 minutes with `best_metric=0.0` because `study.target = "docs-articles"` but `judgment_list.target = "e2e-target"` (an E2E-test leftover).
+- **Outcome:** `POST /api/v1/studies` rejects the mismatch at create time with a specific machine-readable error code (`JUDGMENT_TARGET_MISMATCH`, 422). The create-study modal pre-filters the judgment-list dropdown to only judgment lists matching the selected target, so the operator can't even submit the mismatched pair. The backend rejection is the defense-in-depth net for non-modal callers (chat-agent `create_study` tool, direct API users).
+- **Non-goal:** Detecting *same-target-but-stale* mismatches (re-indexed corpus, judgments authored on a rotated index, etc.) — that's the sibling [feat_study_preflight_overlap_probe](../feat_study_preflight_overlap_probe/idea.md). Detecting mid-flight signal loss is the sibling [feat_orchestrator_zero_streak_abort](../feat_orchestrator_zero_streak_abort/idea.md). This feature covers ONLY the deterministic string-equality case.
+
+## 2) Current state audit
+
+### Existing implementations
+
+- **`backend/app/api/v1/studies.py:240-247`** — the `POST /api/v1/studies` cross-entity check that enforces `judgment_list.query_set_id == body.query_set_id`. Returns generic `VALIDATION_ERROR` (422). The new target-mismatch check sits directly after this, using the same `_err(...)` helper at [`studies.py:74-78`](../../../../backend/app/api/v1/studies.py#L74-L78). No structural change to the handler; one additional `if` block.
+- **`backend/app/db/repo/judgment_list.py:58-112`** — `list_judgment_lists()` already accepts `query_set_id` + `cluster_id` filters (added by `bug_judgment_lists_listing_ignores_query_set_filter`, PR #163). The new `?target=` filter follows the same shape: parameter on the function signature → `WHERE` clause guarded by `if target is not None`.
+- **`backend/app/db/repo/judgment_list.py:115-140`** — `count_judgment_lists()` mirrors the listing filters for `X-Total-Count` consistency. Must add `target` symmetrically.
+- **`backend/app/api/v1/judgments.py:113-122`** — `_summary()` builder for the list response. Currently omits `target`. Detail at [`judgments.py:125-146`](../../../../backend/app/api/v1/judgments.py#L125-L146) includes `target=row.target`. The summary must add the same field so frontends can filter and label by target without fetching detail per row.
+- **`backend/app/api/v1/schemas.py:760-769`** — `JudgmentListSummary` Pydantic model. Add `target: str` field (the underlying ORM column at [`backend/app/db/models/judgment_list.py:46`](../../../../backend/app/db/models/judgment_list.py#L46) is `Text, nullable=False`, so the Pydantic field is non-optional).
+- **`backend/app/api/v1/judgments.py:339-383`** — `list_judgment_lists_endpoint` route. Add `target` query parameter alongside the existing `query_set_id` + `cluster_id` params (those are `Annotated[str | None, Query(min_length=1, max_length=36)]` because they're UUIDv7-shaped FKs). `target` is free-form so its bound is `min_length=1, max_length=255` (the ES/OpenSearch index-name ceiling — the underlying `Text` column has no length limit at the DB layer).
+- **`ui/src/components/studies/create-study-modal.tsx:190-193`** — current `useJudgmentLists` call passes only `{ query_set_id, limit: 200 }`. Extend to `{ query_set_id, cluster_id, target, limit: 200 }`. `cluster_id` adoption is symmetric with the new `target` adoption (wire param exists, modal already has `clusterId` in scope at line 142; passing both is cheaper than passing one without the other).
+- **`ui/src/components/studies/create-study-modal.tsx:594-597`** — existing `query_set_id`-change handler that resets `judgment_list_id` to `''`. New mirror handler on `target` change (at the existing Step-1 target picker at lines 539-561) must call `form.setValue('judgment_list_id', '')` for the same reason (a stale judgment_list_id from a prior target survives in the form state otherwise).
+- **`ui/src/lib/api/judgments.ts:30-35`** — `JudgmentListsFilter` TypeScript interface. Add `target?: string | undefined` field.
+- **`ui/src/lib/api/judgments.ts:37-51`** — `useJudgmentLists` hook. Destructure + pass `target` through `params` (mirrors the existing `query_set_id` + `cluster_id` pattern).
+- **`ui/src/lib/types.ts`** — generated from live OpenAPI; must be regenerated after the `JudgmentListSummary` shape change.
+
+### Navigation and link impact
+
+| Source file | Current link target | New link target |
+|---|---|---|
+| (none) | (no URL/route changes) | — |
+
+### Existing test impact
+
+| Test file | Pattern | Count | Required change |
+|---|---|---|---|
+| `backend/tests/contract/test_studies_error_codes.py` | Asserts error envelopes for `POST /api/v1/studies` failure paths | (no change) | Add 1 new case for `JUDGMENT_TARGET_MISMATCH` (422). |
+| `backend/tests/contract/test_studies_api_contract.py` | Asserts response shapes for studies endpoints | (no change) | (No change needed — this spec doesn't alter the success path of POST /studies.) |
+| `backend/tests/contract/test_judgments_api_contract.py` | Asserts `JudgmentListSummary` OpenAPI shape | 1 | Update fixture to expect the new `target` field on the summary; assert OpenAPI surface lists `target` as required. |
+| `backend/tests/integration/test_studies_api.py` | Studies POST integration tests (target='stub-index' at line 73) | (no change) | Add 1 new case that seeds a study + judgment list with mismatched `target` values → asserts 422 + `JUDGMENT_TARGET_MISMATCH`. |
+| `backend/tests/integration/test_judgments_api.py` | Tests for `GET /api/v1/judgment-lists` listing | 1 | Add 1 new case asserting `?target=X` filters correctly across 2 lists with different targets. |
+| `ui/src/components/studies/__tests__/create-study-modal*.test.tsx` | Create-study modal unit tests | (no change) | Add 1 vitest case asserting the dropdown filters by `target`; add 1 case asserting `target`-change resets `judgment_list_id`. |
+| `ui/src/components/query-sets/associated-judgment-lists.tsx` (callers using `useJudgmentLists` without `target`) | Calls `useJudgmentLists({ query_set_id, limit: 50 })` | 1 | No change — `target` is optional; existing callers continue to work unfiltered (additive change). |
+| `ui/src/app/page.tsx:66` (dashboard count widget) | Calls `apiClient.get('/api/v1/judgment-lists', ...)` for `X-Total-Count` header | 1 | No change — header-only call, additive `target` field on summary is ignored. |
+
+### Existing behaviors affected by scope change
+
+- **Existing studies with mismatched targets:** Current: pass create-time validation (only `query_set_id` cross-check exists); orchestrator runs all trials at 0.0 metric; finalize cleanly. New: such studies cannot be created via `POST /api/v1/studies`. Existing queued/running rows that already passed the (weaker) prior check are NOT retroactively rejected. **Decision needed: no** — locked in idea.md "Locked decisions". Mid-flight detection of pre-existing rows is owned by [feat_orchestrator_zero_streak_abort](../feat_orchestrator_zero_streak_abort/idea.md).
+- **`JudgmentListSummary` wire shape:** Current: 7 fields (`id, name, description, query_set_id, cluster_id, status, created_at`). New: 8 fields with `target: str` added. **Decision needed: no** — additive for tolerant JSON consumers (the existing TanStack Query callers + the dashboard count widget at `ui/src/app/page.tsx:66` ignore unknown fields), but NOT no-impact for strict consumers: the OpenAPI snapshot at `backend/tests/contract/test_openapi_surface.py` and the generated TS types at `ui/src/lib/types.ts` BOTH must be regenerated in the same PR. Any future external client generating from the OpenAPI must also re-pull.
+- **`GET /api/v1/judgment-lists` query params:** Current: 7 params (`cursor, limit, since, q, sort, query_set_id, cluster_id`). New: 8 params with `target` added. **Decision needed: no** — additive optional param; existing callers ignore.
+- **`useJudgmentLists` TanStack queryKey:** Current key shape `['judgment-lists', { query_set_id, cluster_id, cursor, limit }]`. New: adds `target` to the key. Existing cache entries (keyed without `target`) coexist; new entries with `target` set are scoped separately. No cache invalidation needed. **Decision needed: no**.
+
+---
+
+## 3) Scope
+
+### In scope
+
+- **(B1) Backend create-time rejection — target.** New `if judgment_list.target != body.target: raise _err(422, "JUDGMENT_TARGET_MISMATCH", ...)` block immediately after the existing `query_set_id` cross-check at `studies.py:240-247` and after the new cluster_id check (B1b).
+- **(B1b) Backend create-time rejection — cluster_id.** New `if judgment_list.cluster_id != body.cluster_id: raise _err(422, "JUDGMENT_CLUSTER_MISMATCH", ...)` block between the `query_set_id` cross-check and the new target check. Closes the cross-cluster judgment-list reuse gap surfaced by GPT-5.5 cycle-1 review.
+- **(B2) Backend listing surface extension.** Add `target: str` field to `JudgmentListSummary`; extend `_summary()` builder to populate it. Add `?target=` query param to `GET /api/v1/judgment-lists`; thread to `list_judgment_lists` + `count_judgment_lists` repo functions.
+- **(B3) Error-code registration.** New `JUDGMENT_CLUSTER_MISMATCH` + `JUDGMENT_TARGET_MISMATCH` rows added to `docs/01_architecture/api-conventions.md` alongside the existing studies-endpoint codes (`SEARCH_SPACE_UNKNOWN_PARAM`, `SEARCH_SPACE_MISSING_DECLARED_PARAM`).
+- **(F1) Frontend dropdown filtering.** Update create-study modal's `useJudgmentLists` call to pass `{ query_set_id, cluster_id, target, limit: 200 }`. The backend `?target=` filter does the work server-side; the dropdown only sees matching rows.
+- **(F2) Frontend cascade reset.** When EITHER `target` OR `cluster_id` changes via the Step-1 picker, reset `judgment_list_id` to `''`. Two reset triggers: target change AND cluster change. Mirrors the existing `query_set_id`-change reset at line 596. Without the cluster trigger, an operator who picks cluster A → target `products` → judgment list → returns to Step 1 → switches cluster to B (target name still `products`) → submits would see the backend FR-1b 422 (because `judgment_list.cluster_id = A ≠ B = body.cluster_id`) — the cascade reset closes the loop in-UI.
+- **(F3) Frontend empty-state copy.** When zero judgment lists match, render an empty-state via the existing `EntitySelect.emptyState` prop (precedent at [`create-study-modal.tsx:556-561`](../../../../ui/src/components/studies/create-study-modal.tsx#L556-L561) for the targets dropdown's `target_filter`-aware empty state). Copy: `"No judgment lists for target \"{target}\" on this cluster + query set. Generate a new one from /judgments."` with a `cta` linking to `/judgments`.
+- **(F4) Frontend type regeneration.** Regenerate `ui/src/lib/types.ts` from live OpenAPI after the `JudgmentListSummary` shape change. Add `target?: string` to the `JudgmentListsFilter` interface in `ui/src/lib/api/judgments.ts`.
+
+### Out of scope
+
+- **Stale-judgment / overlap detection** — same target name, doc IDs disjoint (post-reindex, etc.). Owned by [feat_study_preflight_overlap_probe](../feat_study_preflight_overlap_probe/idea.md).
+- **Mid-flight zero-streak abort** — orchestrator-level catch for studies that pass create-time gates but produce all-zero trials anyway. Owned by [feat_orchestrator_zero_streak_abort](../feat_orchestrator_zero_streak_abort/idea.md).
+- **Retroactive rejection of pre-existing studies.** Pre-existing queued/running rows with mismatched targets continue executing.
+- **Renaming the existing `VALIDATION_ERROR` code** returned by the `query_set_id` cross-check at `studies.py:242-247`. `api-conventions.md` line 196 forbids renaming shipped codes; the inconsistency (one specific, one generic) is accepted in exchange for backwards compatibility.
+- **Enforcing `query_set.cluster_id == body.cluster_id` at `POST /studies`.** The studies handler at [`backend/app/api/v1/studies.py:228-247`](../../../../backend/app/api/v1/studies.py#L228-L247) does NOT currently cross-check that the query_set's `cluster_id` matches the study's `cluster_id`. This pre-existing gap is independent of the judgment-list mismatch this feature closes — queries themselves are plain text strings and don't have cluster-scoped doc IDs, so a `query_set.cluster_id` mismatch doesn't directly cause the zero-signal failure mode this feature targets. If a follow-up wants to close this contract inconsistency, file as `bug_studies_query_set_cluster_consistency`. The create-study modal already cascade-resets `query_set_id` on cluster change (verified at [`create-study-modal.tsx:508`](../../../../ui/src/components/studies/create-study-modal.tsx#L508)), so the operator can't accidentally trigger this from the UI.
+- **Migration.** No schema changes; `judgment_lists.target` already exists.
+- **Audit-event emission.** Pre-MVP2 — `audit_log` table not present yet.
+- **Display of target in the dropdown rows.** The dropdown item label remains `j.name` (per [`create-study-modal.tsx:608`](../../../../ui/src/components/studies/create-study-modal.tsx#L608)) — adding a target subtitle is an ergonomic improvement deferred to a follow-up `chore_*` if needed; the empty-state copy already surfaces the target value to the operator.
+- **Deep-link prefill on the empty-state CTA.** The `/judgments` page in MVP1 does not support URL prefill query params for the judgment-generation modal (verified — the route's create modal opens with empty defaults). Deep-linking `/judgments?cluster_id=...&query_set_id=...&target=...` would require a separate follow-up adding prefill support to the judgments-generate flow. For this feature, the CTA href is the static `/judgments` and the operator copies the target value mentally from the empty-state copy.
+
+### API convention check
+
+- **Endpoint prefix convention:** `/api/v1/` for business endpoints. Verified in [`backend/app/api/v1/studies.py:188`](../../../../backend/app/api/v1/studies.py#L188) (POST `/studies`) and [`backend/app/api/v1/judgments.py:335`](../../../../backend/app/api/v1/judgments.py#L335) (GET `/judgment-lists`). No new endpoints introduced.
+- **Router file:** `backend/app/api/v1/studies.py` (B1); `backend/app/api/v1/judgments.py` (B2 endpoint).
+- **HTTP methods:** No new endpoints; the existing POST `/studies` and GET `/judgment-lists` keep their methods.
+- **Non-auth error envelope shape:** `{ "detail": { "error_code": "", "message": "", "retryable": } }` per the `_err(...)` helper at [`studies.py:74-78`](../../../../backend/app/api/v1/studies.py#L74-L78) (identical to [`judgments.py:86-90`](../../../../backend/app/api/v1/judgments.py#L86-L90)). The new `JUDGMENT_TARGET_MISMATCH` follows this exact shape.
+- **Auth error shape:** N/A in MVP1 (no auth surface).
+
+### Phase boundaries
+
+Single phase. The entire feature ships in one PR. No deferred phases.
+
+## 4) Product principles and constraints
+
+- **Fail fast on deterministic problems.** A string-equality mismatch costs one comparison; catch it at the API boundary, not 4.5 minutes into the trial budget.
+- **Backend contract, frontend UX.** The 422 is the contract; the modal filter is a UX prefetch. Chat-agent and direct API callers fall through to the same backend gate (no separate enforcement).
+- **Specific over generic error codes** when the failure has a deterministic recovery path. The frontend can render a targeted helper UI (link to judgment generation against the right target) instead of a generic toast.
+- **Don't rename shipped error codes.** `VALIDATION_ERROR` returned by the existing `query_set_id` cross-check stays as-is per `api-conventions.md:196`.
+
+### Anti-patterns
+
+- **Do not** make the frontend filter judgment lists client-side after over-fetching — duplicates the count-semantics work the backend already does, and the dropdown's "no matches" empty state becomes inconsistent with `X-Total-Count`. Use the wire filter.
+- **Do not** add `target` only to `JudgmentListDetail` and require a per-row detail fetch on the frontend — that's N+1 latency on the modal open. Add it to the summary.
+- **Do not** rename the existing `VALIDATION_ERROR` returned for the `query_set_id` mismatch — would silently break any external caller (or future chat-agent error handler) branching on that code.
+- **Do not** raise the new error before the FK-existence and `query_set_id` checks. The order in [`studies.py:206-247`](../../../../backend/app/api/v1/studies.py#L206-L247) is meaningful (FK first → query_set consistency → target consistency); reordering changes which 404 vs 422 the caller sees for ambiguous failures.
+- **Do not** retroactively reject pre-existing studies. The check fires only at `POST /studies`; existing rows are out-of-scope.
+
+## 5) Assumptions and dependencies
+
+- **`judgment_lists.target` is non-nullable.** Confirmed at [`backend/app/db/models/judgment_list.py:46`](../../../../backend/app/db/models/judgment_list.py#L46) (`Mapped[str] = mapped_column(Text, nullable=False)`). Every row has a target, so the equality check is always well-defined.
+- **`studies.target` is non-nullable.** Confirmed at [`backend/app/db/models/study.py:50`](../../../../backend/app/db/models/study.py#L50).
+- **`CreateStudyRequest.target` is required.** Confirmed at [`studies.py:259`](../../../../backend/app/api/v1/studies.py#L259) (`target=body.target` passed unconditionally to `repo.create_study`).
+- **No FTS impact.** `judgment_lists.search_vector` is `GENERATED ALWAYS AS … STORED` over `name + target` per migration `0012_search_vector_judgment_lists.py`. Adding a wire `?target=` filter is orthogonal to the existing FTS `?q=` path — operators can use either.
+- **OpenAPI surface lock.** `backend/tests/contract/test_openapi_surface.py` snapshots the OpenAPI schema. The summary's new `target` field will fail this snapshot until updated — must be in the same PR.
+
+## 6) Actors and roles
+
+- **Primary actor:** Relevance engineer (per umbrella spec §6).
+- **Role model:** N/A — single-tenant install, no auth surface (MVP1).
+- **Permission boundaries:** N/A.
+
+### Authorization
+
+N/A — single-tenant install, no auth surface (per [`docs/01_architecture/tech-stack.md` "Canonical release matrix"](../../../01_architecture/tech-stack.md)).
+
+### Audit events
+
+N/A — `audit_log` lands at MVP2 per [`docs/01_architecture/data-model.md` §"Reserved for later releases"](../../../01_architecture/data-model.md). Pre-MVP2, mutations do not emit audit events.
+
+## 7) Functional requirements
+
+### FR-1: Reject mismatched targets at POST /studies
+
+- Requirement:
+ - The system **MUST** return HTTP 422 with `error_code = "JUDGMENT_TARGET_MISMATCH"` from `POST /api/v1/studies` when the resolved `judgment_list.target` does not equal `body.target` (byte-for-byte string equality — no normalization, no case-folding, no whitespace trimming).
+ - The system **MUST** evaluate this check AFTER the FK resolution (`JUDGMENT_LIST_NOT_FOUND`, `QUERY_SET_NOT_FOUND`, `CLUSTER_NOT_FOUND`, `TEMPLATE_NOT_FOUND`), AFTER the existing `query_set_id` cross-check at [`studies.py:240-247`](../../../../backend/app/api/v1/studies.py#L240-L247), and AFTER the new `cluster_id` cross-check from FR-1b.
+ - The error message **MUST** include both target values for operator diagnosis and **SHOULD** suggest the two recovery paths (regenerate judgments against the study's target, OR change the study target to the judgment list's target).
+ - The error envelope **MUST** match the canonical shape from `_err(...)` at [`studies.py:74-78`](../../../../backend/app/api/v1/studies.py#L74-L78): `{"detail": {"error_code": "JUDGMENT_TARGET_MISMATCH", "message": "...", "retryable": false}}`.
+- Notes: Equality check, no normalization. ES/OpenSearch index names are case-sensitive (lowercased by convention but not enforced); a target string of `Docs-Articles` differs from `docs-articles` at the index layer and must differ here too. The 422 status matches the existing `query_set_id` mismatch precedent at line 242.
+
+### FR-1b: Reject mismatched cluster_id at POST /studies
+
+- Requirement:
+ - The system **MUST** return HTTP 422 with `error_code = "JUDGMENT_CLUSTER_MISMATCH"` from `POST /api/v1/studies` when the resolved `judgment_list.cluster_id` does not equal `body.cluster_id`.
+ - The system **MUST** evaluate this check AFTER the FK resolution and AFTER the existing `query_set_id` cross-check at [`studies.py:240-247`](../../../../backend/app/api/v1/studies.py#L240-L247), and BEFORE the FR-1 target check (cluster mismatch is the broader failure — even with matching target name, different physical clusters have different doc IDs).
+ - The error envelope **MUST** match the canonical shape: `{"detail": {"error_code": "JUDGMENT_CLUSTER_MISMATCH", "message": "...", "retryable": false}}`.
+- Notes: This closes the residual gap GPT-5.5 cycle-1 surfaced — without this check, a chat-agent or direct API caller could submit `body.cluster_id=B` + `judgment_list_id=` (with `judgment_list.cluster_id=A`, `judgment_list.target="foo"`) + `body.target="foo"` and pass FR-1 because target names match. Doc IDs are scoped to the physical cluster, so distinct clusters with the same target name still produce zero overlap. The `judgment_list.cluster_id` column exists at [`backend/app/db/models/judgment_list.py:45`](../../../../backend/app/db/models/judgment_list.py#L45) (`String(36) NOT NULL FK clusters.id`).
+
+### FR-2: Extend GET /api/v1/judgment-lists with ?target= filter
+
+- Requirement:
+ - The system **MUST** accept an optional `?target=` query parameter on `GET /api/v1/judgment-lists`.
+ - When `target` is present, the system **MUST** filter the listing AND the `X-Total-Count` header to only rows where `judgment_lists.target = target` (exact byte-for-byte match, no `LIKE`, no normalization).
+ - The system **MUST** combine `?target=` with the existing `?query_set_id=` + `?cluster_id=` filters using AND semantics (consistent with the pattern at [`backend/app/db/repo/judgment_list.py:87-90, 133-136`](../../../../backend/app/db/repo/judgment_list.py#L87-L90)).
+ - The parameter **MUST** be bounded `min_length=1, max_length=255`. Rationale: 255 is the Elasticsearch / OpenSearch index-name ceiling (the only meaningful cap on a target string today; the underlying `Text` column has no length limit at the DB layer). Out-of-bounds → 422 with `error_code = "VALIDATION_ERROR"` per the canonical envelope from `validation_exception_handler` at [`backend/app/api/errors.py:102-118`](../../../../backend/app/api/errors.py#L102-L118) (the project translates FastAPI's `RequestValidationError` into the standard `{detail: {error_code, message, retryable}}` shape — not FastAPI's default `detail: [{...}]` list).
+- Notes: Out-of-bounds (length 0 or >255) returns the canonical `VALIDATION_ERROR` envelope — no new code needed. The same envelope applies to the existing `query_set_id` / `cluster_id` length violations on this endpoint.
+
+### FR-3: Add target to JudgmentListSummary
+
+- Requirement:
+ - The system **MUST** add `target: str` (non-nullable) to the `JudgmentListSummary` Pydantic model at [`backend/app/api/v1/schemas.py:760-769`](../../../../backend/app/api/v1/schemas.py#L760-L769).
+ - The `_summary()` builder at [`backend/app/api/v1/judgments.py:113-122`](../../../../backend/app/api/v1/judgments.py#L113-L122) **MUST** populate the new field from `row.target`.
+ - The OpenAPI surface contract test **MUST** be updated to assert `target` is present in the `JudgmentListSummary` schema definition.
+- Notes: Additive non-breaking change. Existing TanStack Query consumers tolerate unknown additive fields. The OpenAPI shape-lock test catches the surface drift and must be updated in the same PR.
+
+### FR-4: Frontend create-study modal — target-aware dropdown + cascade reset
+
+- Requirement:
+ - The frontend **MUST** pass `target` (sourced from `form.watch('target')`) through the `useJudgmentLists` filter at [`create-study-modal.tsx:190-193`](../../../../ui/src/components/studies/create-study-modal.tsx#L190-L193): `useJudgmentLists({ query_set_id: querySetId || undefined, cluster_id: clusterId || undefined, target: target || undefined, limit: 200 })`. `cluster_id` is added at the same time for symmetry.
+ - When EITHER `target` (via the Step-1 picker — `` manual mode at line 527, `` dropdown mode at lines 546-561) OR `cluster_id` (via the cluster picker — existing `onChange` at line 504-507 already resets `target` per the prior FR-4 cascade; this spec extends that same handler to ALSO reset `judgment_list_id`) changes, the frontend **MUST** reset `judgment_list_id` to `''`. Mirror the existing `query_set_id`-change reset at line 596.
+ - When the filtered list returns zero rows, the dropdown **MUST** render the existing `EntitySelect.emptyState` (per [`entity-select.tsx:33-36`](../../../../ui/src/components/common/entity-select.tsx#L33-L36)) with message `"No judgment lists for target \"{target}\" on this cluster + query set. Generate a new one from /judgments."` and `cta = { label: "Generate judgments", href: "/judgments" }`.
+ - The `JudgmentListsFilter` interface at [`ui/src/lib/api/judgments.ts:30-35`](../../../../ui/src/lib/api/judgments.ts#L30-L35) **MUST** add `target?: string | undefined`.
+ - The TanStack queryKey **MUST** include `target` so cache entries are correctly scoped (existing cache entries keyed without `target` remain valid for filter-less callers like `associated-judgment-lists.tsx`).
+ - The generated OpenAPI types at `ui/src/lib/types.ts` **MUST** be regenerated to reflect the new `JudgmentListSummary.target` field and the new wire filter param.
+- Notes: Step 1 (`'Cluster + target'`) gates Step-2 advance with `Boolean(values.cluster_id && values.target)` (per [`create-study-modal.tsx:384`](../../../../ui/src/components/studies/create-study-modal.tsx#L384)) — `target` is always set by the time the user reaches the judgment-list dropdown, so the "no target yet" branch the original idea proposed is dead code and not implemented.
+
+## 8) API and data contract baseline
+
+### 7.1 Endpoint surface
+
+| Method | Path | Purpose | Key error codes |
+|---|---|---|---|
+| `POST` | `/api/v1/studies` | Create + enqueue start_study | `JUDGMENT_CLUSTER_MISMATCH` (422 — new, fires first), `JUDGMENT_TARGET_MISMATCH` (422 — new); existing: `CLUSTER_NOT_FOUND` (404), `TEMPLATE_NOT_FOUND` (404), `QUERY_SET_NOT_FOUND` (404), `JUDGMENT_LIST_NOT_FOUND` (404), `INVALID_SEARCH_SPACE` (400), `SEARCH_SPACE_UNKNOWN_PARAM` (400), `SEARCH_SPACE_MISSING_DECLARED_PARAM` (400), `VALIDATION_ERROR` (422). |
+| `GET` | `/api/v1/judgment-lists` | List with cursor pagination + filters | `VALIDATION_ERROR` (422 — cursor decode or query-param bounds). |
+
+### 7.2 Contract rules
+
+- Error body **MUST** include `error_code` (machine-readable, never renamed once shipped).
+- Status code 422 **MUST** be deterministic per scenario: target mismatch → `JUDGMENT_TARGET_MISMATCH`; query_set mismatch (existing) → `VALIDATION_ERROR`; FK lookup → 404 with the specific entity code.
+- The new validator **MUST** fire AFTER all FK resolutions to preserve the 404-before-422 ordering operators rely on.
+
+### 7.3 Response examples
+
+**Success — POST /api/v1/studies (existing shape, unchanged):**
+```json
+{
+ "id": "01990000-0000-7000-8000-000000000001",
+ "name": "tune-products-boost",
+ "cluster_id": "01990000-0000-7000-8000-000000000010",
+ "target": "products",
+ "template_id": "01990000-0000-7000-8000-000000000020",
+ "query_set_id": "01990000-0000-7000-8000-000000000030",
+ "judgment_list_id": "01990000-0000-7000-8000-000000000040",
+ "search_space": {"params": {"boost": {"kind": "float", "low": 0.5, "high": 10}}},
+ "objective": {"metric": "ndcg", "k": 10, "direction": "maximize"},
+ "config": {"max_trials": 100, "sampler": "tpe", "pruner": "median"},
+ "status": "queued",
+ "failed_reason": null,
+ "optuna_study_name": "01990000-0000-7000-8000-000000000001",
+ "...": "...remaining StudyDetail fields..."
+}
+```
+
+**Failure — POST /api/v1/studies, target mismatch (NEW):** HTTP 422
+```json
+{
+ "detail": {
+ "error_code": "JUDGMENT_TARGET_MISMATCH",
+ "message": "judgment_list target='e2e-target' does not match study target='products'; judgments would have no overlap with search results from the study's target. Use a judgment list generated against 'products' or change study.target to 'e2e-target'.",
+ "retryable": false
+ }
+}
+```
+
+**Success — GET /api/v1/judgment-lists?target=products (NEW filter, summary shape extended):** HTTP 200, header `X-Total-Count: 2`
+```json
+{
+ "data": [
+ {
+ "id": "01990000-0000-7000-8000-000000000040",
+ "name": "products-judgments-v1",
+ "description": "Initial 200-judgment seed.",
+ "query_set_id": "01990000-0000-7000-8000-000000000030",
+ "cluster_id": "01990000-0000-7000-8000-000000000010",
+ "target": "products",
+ "status": "complete",
+ "created_at": "2026-05-20T18:42:11.000Z"
+ },
+ {
+ "id": "01990000-0000-7000-8000-000000000041",
+ "name": "products-judgments-v2",
+ "description": null,
+ "query_set_id": "01990000-0000-7000-8000-000000000030",
+ "cluster_id": "01990000-0000-7000-8000-000000000010",
+ "target": "products",
+ "status": "complete",
+ "created_at": "2026-05-21T09:14:02.000Z"
+ }
+ ],
+ "next_cursor": null,
+ "has_more": false
+}
+```
+
+**Failure — GET /api/v1/judgment-lists?target=<empty>:** HTTP 422 (FastAPI's `VALIDATION_ERROR` for `min_length=1` violation; shape per FastAPI's `RequestValidationError` handler — same as any other bounds violation on this endpoint, no new code).
+
+### 7.4 Enumerated value contracts
+
+| Field | Accepted values (exact) | Backend source of truth | Frontend call site(s) |
+|---|---|---|---|
+| `POST /studies` `error_code` | `JUDGMENT_CLUSTER_MISMATCH`, `JUDGMENT_TARGET_MISMATCH` (both new, in firing order); existing per `studies.py` `_err(...)` call sites | `backend/app/api/v1/studies.py` (`_err(...)` invocations) — codes are string literals, not a centralized Literal | None on frontend yet — the modal pre-filters so the operator can't submit a mismatch. The chat agent's `create_study` tool surfaces the error via the orchestrator's existing 422-handler; no new branching needed. |
+| `GET /judgment-lists` `?target=` | Any non-empty string up to 255 chars | `judgments.py:347-348` (FastAPI `Annotated[str \| None, Query(min_length=1, max_length=255)]`) | `useJudgmentLists` filter — but the value flows through opaquely; no allowlist on the frontend side. |
+
+No new option lists, status enums, or filter chips introduced. No new `
` closing Step 1.
+
+### Analogous markup patterns
+
+**Pattern 1: emptyState prop with CTA — from [`create-study-modal.tsx:556-561`](../../../../ui/src/components/studies/create-study-modal.tsx#L556-L561) (targets `target_filter` empty state):**
+
+```tsx
+emptyState={{
+ message: selectedCluster?.target_filter
+ ? `No targets match filter "${selectedCluster.target_filter}" on this cluster. To change the filter, delete and re-register the cluster — MVP1 has no in-place edit for cluster registrations.`
+ : 'No targets found on this cluster.',
+}}
+```
+
+Story 2.1 adapts this for the judgment-list dropdown. Note: spec FR-4 says Step-2 is target-gated (the user cannot reach the dropdown without `target` set), so the empty-state message is UNCONDITIONAL with no fallback branch — do not copy the targets-dropdown's `? :` pattern verbatim. Story 2.1 also adds a `cta` field (already supported by [`entity-select.tsx:33-36`](../../../../ui/src/components/common/entity-select.tsx#L33-L36)):
+
+```tsx
+emptyState={{
+ message: `No judgment lists for target "${target}" on this cluster + query set. Generate a new one from /judgments.`,
+ cta: { label: 'Generate judgments', href: '/judgments' },
+}}
+```
+
+**Pattern 2: cascade-reset onChange — from [`create-study-modal.tsx:594-597`](../../../../ui/src/components/studies/create-study-modal.tsx#L594-L597) (query-set picker resets judgment_list_id):**
+
+```tsx
+onChange={(v) => {
+ form.setValue('query_set_id', v ?? '');
+ form.setValue('judgment_list_id', '');
+}}
+```
+
+Story 2.1 mirrors this on the target dropdown:
+
+```tsx
+onChange={(v) => {
+ form.setValue('target', v ?? '');
+ form.setValue('judgment_list_id', '');
+}}
+```
+
+**Pattern 3: cluster-cascade onChange (already correct, no change needed) — from [`create-study-modal.tsx:502-514`](../../../../ui/src/components/studies/create-study-modal.tsx#L502-L514):**
+
+```tsx
+onChange={(v) => {
+ form.setValue('cluster_id', v ?? '');
+ form.setValue('target', '');
+ form.setValue('query_set_id', '');
+ form.setValue('judgment_list_id', '');
+ form.setValue('template_id', '');
+ setManualMode(false);
+}}
+```
+
+This already covers AC-12 (cluster change resets judgment_list_id). The vitest test exists to lock the behavior against regression — no code change needed here.
+
+### Layout and structure
+
+No layout changes. The empty-state rendering is owned by the `` primitive ([`entity-select.tsx`](../../../../ui/src/components/common/entity-select.tsx)) — the modal just supplies the `emptyState` prop.
+
+### Confirmation/modal dialog pattern
+
+N/A — no new dialogs introduced.
+
+### Visual consistency table
+
+| New UI element | CSS class / pattern source |
+|---|---|
+| Empty-state copy + CTA in judgment-list dropdown | Reuses `EntitySelectEmptyState` interface from [`entity-select.tsx:33-36`](../../../../ui/src/components/common/entity-select.tsx#L33-L36); rendered by the existing `` empty-state branch — no new CSS needed. |
+
+### Component composition
+
+All changes are inline within the existing `` function component. No new extracted components.
+
+### Interaction behavior table
+
+| User action | Frontend behavior | API call |
+|---|---|---|
+| Picks target in Step 0 (dropdown OR manual) | `target` form value set; `judgment_list_id` reset to `''`; Step-1 advance gate fails until user re-advances. | `GET /api/v1/judgment-lists?query_set_id=<Q>&cluster_id=<C>&target=<T>&limit=200` (fires only after the user advances to Step 1) |
+| Changes target in Step 0 after having picked a judgment list in Step 1 | `judgment_list_id` reset; Step-1 shows the dropdown with the new filtered results. | Same as above with new target value. |
+| No matching judgment lists for filter | Empty-state copy renders with exact target value + "Generate judgments" CTA to `/judgments`. | (no new call — `useJudgmentLists` returned `data: []`) |
+| Changes cluster (any step) | All downstream form values reset (per existing handler at line 502-514). | `GET /api/v1/judgment-lists?query_set_id=<new Q>&cluster_id=<new C>&target=<new T>&limit=200` after the user re-advances. |
+
+### Handler function patterns
+
+The dropdown handler is straightforward `form.setValue` (already off-RHF-register — `` is a controlled component via `value`/`onChange` props):
+
+```typescript
+// Target dropdown onChange — pattern from line 594-597 (query-set picker)
+const onTargetDropdownChange = (v: string | undefined): void => {
+ form.setValue('target', v ?? '');
+ form.setValue('judgment_list_id', '');
+};
+```
+
+The manual-input handler MUST preserve RHF's registered `onChange` to keep `dirty`/`touched`/`validate` state correct. Hoist the register result and call its `onChange` first, then run the cascade reset:
+
+```tsx
+// In the modal body, BEFORE the JSX return:
+const targetReg = form.register('target');
+
+// In Step-0 manual-input JSX (replaces `{...form.register('target')}`):
+ {
+ targetReg.onChange(e); // RHF: updates value + dirty/touched + runs registered validate
+ form.setValue('judgment_list_id', ''); // cascade
+ }}
+ placeholder="products"
+/>
+```
+
+**Do NOT** use the simpler `form.setValue('target', e.target.value)` pattern in the manual handler — it bypasses RHF's registered `onChange` and can break the existing TARGETS_FORBIDDEN auto-engage path at lines 170-173.
+
+### Information architecture placement
+
+No nav changes. Feature lives within the existing create-study modal (Step 0 → Step 1 cascade). No new routes, no new tabs.
+
+### Tooltips and contextual help
+
+No new tooltip placements per spec §11 ("Tooltips and contextual help" section says "no new tooltip placements; existing `study.judgment_list` glossary entry already explains the field"). The empty-state copy itself is the contextual help when the dropdown is empty.
+
+### Legacy behavior parity
+
+**No legacy behavior parity table — no user-facing component >100 LOC is being deleted or migrated in this plan.** Story 2.1 extends three existing UI elements (judgment-list ``, target manual ``, target dropdown ``) in-place; no JSX is removed.
+
+### Client-side persistence
+
+N/A — no localStorage/sessionStorage usage in this feature.
+
+### Enumerated value contract
+
+No new enumerated `` options. The new `?target=` wire param is a free-form string (255-char bounded), not an allowlisted enum. No source-of-truth comment needed on the frontend for option arrays because no option arrays are introduced.
+
+---
+
+## 3) Testing workstream
+
+### 3.1 Unit tests
+
+- **Location:** `backend/tests/unit/`
+- **Scope:** None new — no new pure-domain logic. The two validators are inline conditionals on the route handler.
+- **Tasks:** none.
+- **DoD:** N/A.
+
+### 3.2 Integration tests
+
+- **Location:** `backend/tests/integration/`
+- **Scope:** DB-backed studies POST validation + judgment-lists filtering.
+- **Tasks:**
+ - [ ] In `test_studies_api.py`: add `test_create_study_rejects_target_mismatch()` — seeds matching cluster + mismatched target; asserts 422 + `JUDGMENT_TARGET_MISMATCH`. Assigned to Story 1.2 DoD.
+ - [ ] In `test_studies_api.py`: add `test_create_study_rejects_cluster_mismatch()` — seeds 2 clusters + judgment list on C1 + study body with C2; asserts 422 + `JUDGMENT_CLUSTER_MISMATCH`. Assigned to Story 1.2 DoD.
+ - [ ] In `test_studies_api.py`: add `test_create_study_cluster_mismatch_fires_before_target()` — both cluster AND target mismatch; asserts `JUDGMENT_CLUSTER_MISMATCH` (not target). Assigned to Story 1.2 DoD (AC-11).
+ - [ ] In `test_studies_api.py`: add `test_create_study_happy_path_matching_target_and_cluster()` — asserts 201 with the existing `StudyDetail` shape (AC-2). Assigned to Story 1.2 DoD.
+ - [ ] In `test_studies_api.py`: add `test_studies_get_does_not_validate_pre_existing_target_mismatch()` — seeds a study row with mismatched target via direct DB write; asserts `GET /api/v1/studies/{id}` returns 200 (AC-10). Assigned to Story 1.2 DoD.
+ - [ ] In `test_judgments_api.py`: add `test_list_judgment_lists_target_filter()` — seeds 3 lists (2 with `target=A`, 1 with `target=B`); asserts `?target=A` returns 2 rows + `X-Total-Count: 2`; `?target=B` returns 1 row + `X-Total-Count: 1`. Assigned to Story 1.1 DoD (AC-5).
+ - [ ] In `test_judgments_api.py`: add `test_list_judgment_lists_and_semantics()` — seeds 4 lists across 2 clusters × 2 query_sets × shared target `products`; asserts `?target=products&cluster_id=C1&query_set_id=Q1` returns exactly 1 row + `X-Total-Count: 1`. Assigned to Story 1.1 DoD (spec §14 integration case #5).
+- **DoD:** Happy path + all critical failure paths covered; X-Total-Count matches data length under filter.
+
+### 3.3 Contract tests
+
+- **Location:** `backend/tests/contract/`
+- **Scope:** Endpoint shape, status codes, machine-readable error codes, **firing-order locks**.
+- **Tasks:**
+ - [ ] In `test_studies_error_codes.py`: add `test_judgment_target_mismatch_envelope_shape()` — asserts the canonical envelope on the new 422. Assigned to Story 1.2.
+ - [ ] In `test_studies_error_codes.py`: add `test_judgment_cluster_mismatch_envelope_shape()` — asserts the canonical envelope on the new 422. Assigned to Story 1.2.
+ - [ ] In `test_studies_api_contract.py`: add `test_create_study_error_ordering()` — sets up scenarios where multiple errors could fire and asserts the priority: 404 `JUDGMENT_LIST_NOT_FOUND` > 422 `VALIDATION_ERROR` (query_set) > 422 `JUDGMENT_CLUSTER_MISMATCH` > 422 `JUDGMENT_TARGET_MISMATCH`. Assigned to Story 1.2. **Contract-layer assertion locks the order against future refactor.**
+ - [ ] In `test_judgments_api_contract.py`: add `test_judgment_list_summary_includes_target()` — asserts `target` field present + type-`string` on the summary, and `?target=` query param exists. Assigned to Story 1.1.
+ - [ ] In `test_openapi_surface.py`: verify the snapshot/schema check accepts the new `JudgmentListSummary.target` schema property AND the new `?target=` query param on `GET /api/v1/judgment-lists`. **NOT in scope:** enumerating the new error codes on the POST /studies row — the existing surface test (lines 75-82) only asserts `(method, path, success_status)` tuples and does not enumerate per-endpoint error responses (no route uses FastAPI `responses=` metadata to declare 4xx envelopes in OpenAPI). Error codes are locked at the runtime layer via `test_studies_error_codes.py` + the new ordering test in `test_studies_api_contract.py` + the `api-conventions.md` documentation row — that combination is sufficient and matches the existing repo pattern. Owned by Story 1.1 (the only story changing OpenAPI surface). Story 1.2 does not change OpenAPI surface (handler-body `_err(...)` raises don't appear in OpenAPI).
+- **DoD:** Every accepted endpoint change has contract coverage. Both new error codes locked. Firing order locked at contract layer.
+
+### 3.4 E2E tests
+
+- **Location:** `ui/tests/e2e/`
+- **Scope:** No new specs. Existing `ui/tests/e2e/studies-create-validation.spec.ts` exercises the happy path; the seed helper at [`ui/tests/e2e/helpers/seed.ts:400-413`](../../../../ui/tests/e2e/helpers/seed.ts#L400-L413) already creates judgment lists with `target: 'products'` matching the cluster's `products` index — verified compatible with the new wire filter.
+- **Tasks:**
+ - [ ] Run the existing `studies-create-validation.spec.ts` after the implementation lands; confirm it still passes. Assigned to Story 2.1 DoD.
+ - [ ] Audit: confirm no other E2E spec relies on a judgment-list that has `target ≠ study.target` for the happy path. Audit scope: `grep -rn "judgment.*list\|judgmentList" ui/tests/e2e/` to enumerate touched specs.
+- **DoD:** Existing Playwright suite passes; no new spec needed.
+
+### 3.5 Frontend unit tests
+
+- **Location:** `ui/src/components/studies/__tests__/` and `ui/src/lib/api/__tests__/`.
+- **Tasks:**
+ - [ ] New file `ui/src/components/studies/__tests__/create-study-modal.target-filter.test.tsx` with 5 vitest cases:
+ 1. Asserts `useJudgmentLists` is invoked with `{ query_set_id, cluster_id, target, limit: 200 }` when those values are set on the form.
+ 2a. Asserts `target` change via the **dropdown** `` (`data-testid="cs-target"`) clears `judgment_list_id`.
+ 2b. Asserts `target` change via the **manual** `` (`id="cs-target"` when manual mode is active) clears `judgment_list_id`. Separate from 2a because the handlers are different code paths.
+ 3. Asserts `cluster_id` change clears `judgment_list_id` (regression-lock — behavior already exists at line 509).
+ 4. Asserts the empty-state copy renders with the target value substituted and CTA `href="/judgments"` when the hook returns `{ data: [], next_cursor: null, has_more: false }`.
+ - [ ] New file `ui/src/lib/api/__tests__/judgments.test.tsx` (or extend if it already exists — `grep -rn "useJudgmentLists" ui/src/lib/api/__tests__/` to check): add `test_useJudgmentLists_threads_target_param()` — spy on `apiClient.get` and assert that calling the hook with `{ target: 'X' }` results in a request where `params.target === 'X'` AND that `target` appears in the queryKey. This proves the wire-filtering contract, which the component-level test (case 1) only asserts at the hook-call boundary.
+
+### 3.6 Existing test impact audit
+
+| Test file | Pattern | Count | Action |
+|---|---|---|---|
+| All UI fixtures constructing `JudgmentListSummary` literals (msw mocks, component test fixtures) | `JudgmentListSummary\|judgmentLists\|/api/v1/judgment-lists` | TBD — run `grep -rn "JudgmentListSummary\|judgmentLists\|/api/v1/judgment-lists" ui/src/ ui/tests/` and enumerate | **Update each** — `JudgmentListSummary.target` is now a required field. Object literals missing `target` will fail `pnpm typecheck` after `types.ts` is regenerated. Add `target: ''` to each. Story 2.1 Task 7 owns this. |
+| `ui/src/components/studies/__tests__/create-study-modal*.test.tsx` (and sibling files) | `useJudgmentLists` mock | 1 per file using the modal | Confirm each msw-style mock returns judgment lists shaped with the new `target` field (typecheck will fail otherwise). The `target` filter is optional and existing mocks pass-through regardless. |
+| `ui/src/components/query-sets/associated-judgment-lists.tsx` (production caller) | `useJudgmentLists({ query_set_id, limit: 50 })` | 1 | No code change — additive `target` field on summary is ignored at the consumption site; no filter passed. Confirmed compatible. |
+| `ui/src/app/page.tsx` (dashboard count widget) | `apiClient.get` | 1 | No code change — header-only call. |
+| `backend/tests/contract/test_openapi_surface.py` | OpenAPI snapshot | 1 | **Update** — must regenerate the snapshot to include `JudgmentListSummary.target` + new `?target=` query param. (NOT the new error codes — the existing surface test at lines 75-82 only enumerates success-status tuples; per-endpoint error codes are locked at the runtime layer instead.) Owned by Story 1.1 alone. |
+| `backend/tests/integration/test_judgments_api.py` | Existing list-endpoint tests | (existing) | No code change — new filter is additive; existing assertions remain valid. |
+| `backend/tests/contract/test_studies_api_contract.py` | Existing endpoint contract | (existing) | **Add ordering tests** per §3.3. Story 1.2 owns. |
+
+### 3.7 Migration verification
+
+N/A — no schema changes.
+
+### 3.8 CI gates
+
+- [ ] `make lint` (ruff)
+- [ ] `make typecheck` (mypy --strict)
+- [ ] `make test-unit`
+- [ ] `make test-integration`
+- [ ] `make test-contract`
+- [ ] `cd ui && pnpm lint && pnpm typecheck && pnpm test && pnpm build`
+- [ ] Existing Playwright suite via `make test-e2e` (if available) or `cd ui && pnpm e2e`.
+
+---
+
+## 4) Documentation update workstream
+
+### 4.0 Core context files
+
+- **`state.md`** — update to:
+ - Move feature into the "Most recent meaningful changes" section after PR merge.
+ - Note: no Alembic head change.
+- **`architecture.md`** — no change. Pure API-layer + frontend addition; no new service/layer/flow.
+- **`CLAUDE.md`** — no change. No new convention, env var, or absolute rule.
+
+### 4.1 Architecture docs (`docs/01_architecture`)
+
+- [ ] `api-conventions.md` — add `JUDGMENT_CLUSTER_MISMATCH` + `JUDGMENT_TARGET_MISMATCH` to the studies-endpoint error-code table (after `SEARCH_SPACE_MISSING_DECLARED_PARAM` at lines 76-77). Assigned to Story 1.2.
+
+### 4.2 Product docs (`docs/02_product`)
+
+- This feature is the canonical doc. No other product doc changes.
+
+### 4.3 Runbooks (`docs/03_runbooks`)
+
+- No new runbook. The 422 envelopes are self-explanatory.
+
+### 4.4 Security docs (`docs/04_security`)
+
+- No change — no new attack surface, no new secret.
+
+### 4.5 Quality docs (`docs/05_quality`)
+
+- No change — existing test layers cover the feature.
+
+**Documentation DoD**
+
+- [ ] `state.md` reflects the merged PR.
+- [ ] `api-conventions.md` carries both new error-code rows.
+- [ ] No other doc touched.
+
+---
+
+## 5) Lean refactor workstream
+
+### 5.1 Refactor goals
+
+None. The feature is additive across all 3 stories — no refactor opportunity worth the scope expansion.
+
+### 5.2 Planned refactor tasks
+
+- None.
+
+### 5.3 Refactor guardrails
+
+- N/A.
+
+---
+
+## 6) Dependencies, risks, and mitigations
+
+### Dependencies
+
+| Dependency | Needed by | Status | Risk if missing |
+|---|---|---|---|
+| Story 1.1 `JudgmentListSummary.target` field + `?target=` filter | Story 2.1 | (in-PR — same branch) | Story 2.1's `useJudgmentLists({ target })` would silently filter nothing without the wire param; the OpenAPI types regen would also fail. Order Stories 1.1 → 2.1 within the branch. |
+| GPT-5.5 cross-model review on the implementation | Final PR review | Configured per `.env` | If unavailable, fall back to Opus-only review with explicit log entry per `impl-execute` skill protocol. |
+
+### Risks
+
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| Pre-existing test fixtures across the repo use mismatched (cluster, target) judgment-list pairings and break after the new validators land | L | H | Story 1.2 task list includes a `grep` audit across `backend/tests/integration/` for `judgment_list_id` usage in study POST setups, and `ui/tests/e2e/helpers/seed.ts` for the same. Confirmed clean in spec §14 (seed helper uses matching `target='products'`). Re-verify during impl-execute. |
+| OpenAPI types regen fails because the live dev container isn't running | L | L | Regen is part of Story 1.1; if the project's standard regen workflow isn't immediately available, wire it up in the PR (e.g., add a `make openapi-export` target or `pnpm run openapi:generate` script). **Manual hand-edits of `ui/src/lib/types.ts` are NOT permitted** — they drift from the OpenAPI source and miss new query params or required fields. |
+| Cycle-1 GPT-5.5 review on the plan re-surfaces the cluster_id finding (since the spec review already raised it) | L | L | Plan explicitly carries FR-1b through Story 1.2 + tests + docs. Cycle-1 should converge fast. |
+| Frontend regression: existing `register('target')` -> explicit Controller pattern change breaks form keyboard navigation | L | M | Vitest case 2 explicitly asserts the manual-input cascade. Real-backend Playwright spec exercises the manual-input path end-to-end. Mitigated by tests. |
+
+### Failure mode catalog
+
+| Failure mode | Trigger | Expected system behavior | Recovery |
+|---|---|---|---|
+| Mismatched cluster + target via chat agent `create_study` tool | LLM hallucinates a judgment_list_id from a different cluster | Backend 422 `JUDGMENT_CLUSTER_MISMATCH`; chat agent's existing error handler surfaces the message | Manual — operator picks correct judgment list |
+| Mismatched target same cluster (rare — only chat agent could hit) | LLM picks a judgment_list with right cluster but wrong target | Backend 422 `JUDGMENT_TARGET_MISMATCH` | Manual — operator picks correct judgment list |
+| Pre-existing studies with mismatched target (created before this feature) | Direct DB row | Read paths return 200 unchanged; orchestrator keeps producing 0-metric trials (out-of-scope; covered by `feat_orchestrator_zero_streak_abort` sibling) | Wait for mid-flight abort (sibling feature, MVP1) or manually cancel |
+| Frontend race: user changes cluster mid-fetch | TanStack queryKey re-computes; old fetch result discarded | New filter applies; empty state may flash briefly | Auto — TanStack's stale-while-revalidate handles |
+
+---
+
+## 7) Sequencing and parallelization
+
+### Suggested sequence
+
+1. **Story 1.1** — backend listing surface extension (FR-2 + FR-3). Backend types must be live before frontend types regen.
+2. **Story 1.2** — backend validators (FR-1 + FR-1b). Independent of Story 1.1.
+3. **Story 2.1** — frontend filter + cascade + empty-state (FR-4). Depends on Story 1.1.
+
+### Parallelization opportunities
+
+- Story 1.1 and Story 1.2 can run in parallel for the production-code edits (different files, no shared state).
+- Story 2.1 must wait for Story 1.1's `JudgmentListSummary.target` + `?target=` wire to land in the working tree before regenerating types.
+- **`test_openapi_surface.py` is owned by Story 1.1 only** (Story 1.2 changes don't surface in OpenAPI — runtime `_err(...)` raises don't add `responses` metadata; the existing surface test only enumerates `(method, path, success_status)` tuples). No final joint sequencing needed.
+
+---
+
+## 8) Rollout and cutover plan
+
+- **Rollout stages:** Single — merge to `main` triggers nothing for MVP1 (no remote staging). Operators pull on next `make up`.
+- **Feature flag strategy:** None. Validation is a hard-gate at the API boundary; staged rollout would mean half the operators get the 422 and half don't.
+- **Migration/cutover steps:** None — no schema changes.
+- **Reconciliation/repair strategy:** None — no external systems involved.
+
+---
+
+## 9) Execution tracker
+
+### Current sprint
+
+- [x] Story 1.1 — Extend GET /judgment-lists with target filter + summary field
+- [x] Story 1.2 — Studies POST cluster + target validators
+- [x] Story 2.1 — Create-study modal: target filter + cascade + empty state
+
+### Blocked items
+
+- None.
+
+### Done this sprint
+
+- [x] Story 1.1 (commit `[backend story commit]`) — JudgmentListSummary.target + ?target= filter + AND-semantics
+- [x] Story 1.2 (commit `[backend story commit]`) — POST /studies JUDGMENT_CLUSTER_MISMATCH + JUDGMENT_TARGET_MISMATCH validators
+- [x] Story 2.1 (commit `a88396b`) — Create-study modal target-aware filter, cascade reset, empty-state CTA
+
+---
+
+## 10) Story-by-Story Verification Gate
+
+Before marking any story complete:
+
+- [ ] Files created/modified match story scope (New files / Modified files tables).
+- [ ] Endpoint contract implemented exactly as documented (status codes + envelope shapes).
+- [ ] Key interfaces implemented with compatible signatures.
+- [ ] Required tests added/updated for all applicable layers.
+- [ ] Commands executed and passed:
+ - [ ] `make test-unit`
+ - [ ] `make test-integration` (subset for touched endpoints)
+ - [ ] `make test-contract`
+ - [ ] `cd ui && pnpm test` (if UI touched)
+ - [ ] `cd ui && pnpm build` (if UI touched)
+- [ ] Migration round-trip evidence: N/A (no schema changes).
+- [ ] `docs/01_architecture/api-conventions.md` updated when Story 1.2 lands.
+
+---
+
+## 11) Plan consistency review
+
+1. **Spec ↔ plan endpoint count:**
+ - Spec §7.1 lists 2 endpoints (POST /studies, GET /judgment-lists).
+ - Plan covers both — Story 1.2 (POST /studies), Story 1.1 (GET /judgment-lists). ✅ Match.
+
+2. **Spec ↔ plan error code coverage:**
+ - Spec §7.5 lists 2 new codes (`JUDGMENT_CLUSTER_MISMATCH`, `JUDGMENT_TARGET_MISMATCH`).
+ - Plan §3.3 has 2 envelope-shape contract tests + 1 ordering-priority contract test in `test_studies_api_contract.py` (locks 404 > VALIDATION_ERROR > CLUSTER_MISMATCH > TARGET_MISMATCH order). ✅ Match.
+ - Existing codes (`VALIDATION_ERROR`, `JUDGMENT_LIST_NOT_FOUND`, etc.) are referenced in DoD ordering tests; the new ordering contract test exercises them as preconditions but doesn't re-cover their individual envelopes (already covered by existing `test_studies_error_codes.py` cases — verified at line 103-107).
+
+3. **Spec ↔ plan FR coverage:**
+ - FR-1: Story 1.2 ✅
+ - FR-1b: Story 1.2 ✅
+ - FR-2: Story 1.1 ✅
+ - FR-3: Story 1.1 ✅
+ - FR-4: Story 2.1 ✅
+ - All 5 FRs covered.
+
+4. **Story internal consistency:**
+ - Story 1.1: Modified files exist (verified: `schemas.py`, `judgments.py`, `judgment_list.py`, `judgments.ts`). Pydantic schema field `target: str` matches the ORM `Text NOT NULL` column. ✅
+ - Story 1.2: Modified files exist (verified: `studies.py`, `api-conventions.md`). The `_err(...)` helper at studies.py:74 matches the envelope shape. ✅
+ - Story 2.1: Modified files exist (verified: `create-study-modal.tsx`, `judgments.ts`, `types.ts`). UI element inventory matches actual modal structure (verified by reading lines 488-614). ✅
+ - No file ownership conflicts.
+
+5. **Test file count and assignment:**
+ - Backend integration: 7 cases across 2 files (test_studies_api.py: 5, test_judgments_api.py: 2). All assigned to Story 1.1 or 1.2. ✅
+ - Backend contract: 5 cases across 4 files (test_studies_error_codes.py: 2, test_studies_api_contract.py: 1 ordering test, test_judgments_api_contract.py: 1, test_openapi_surface.py: 1 — final joint task). All assigned. ✅
+ - Frontend vitest: 1 new test file (`create-study-modal.target-filter.test.tsx`, 5 cases) + 1 hook test case (in new-or-extended `ui/src/lib/api/__tests__/judgments.test.tsx`). All assigned to Story 2.1. ✅
+ - No orphaned test files.
+
+6. **Gate arithmetic:** No epic/phase gates beyond per-story DoD; single epic. N/A.
+
+7. **Open questions resolved:**
+ - Spec §19 lists no open questions. All decisions locked. ✅
+
+8. **Plan ↔ codebase verification:**
+ - `backend/app/db/repo/judgment_list.py` lines 58-140 verified — both `list_judgment_lists` and `count_judgment_lists` exist with `query_set_id` + `cluster_id` filter pattern to mirror. ✅
+ - `backend/app/api/v1/schemas.py:760-769` `JudgmentListSummary` verified — has 7 fields, missing `target`. ✅
+ - `backend/app/api/v1/studies.py:240-247` query_set_id cross-check verified. Insertion point at lines 247-249 is correct. ✅
+ - `ui/src/components/studies/create-study-modal.tsx:502-514` cluster onChange verified — already resets `judgment_list_id`. AC-12 is regression-lock only. ✅
+ - `ui/src/components/common/entity-select.tsx:33-36` `EntitySelectEmptyState` interface verified — already supports `cta`. ✅
+ - `ui/tests/e2e/helpers/seed.ts:400-413` judgment-list seeds with `target='products'` matching its cluster's index. ✅
+ - `backend/app/api/errors.py:102-118` `validation_exception_handler` confirmed to route FastAPI's `RequestValidationError` into the canonical envelope. ✅
+
+9. **Infrastructure path verification:**
+ - Migration directory: N/A (no migration).
+ - Router registration: N/A (no new router; extending existing handlers).
+
+10. **Frontend data plumbing verification:**
+ - Story 2.1: `clusterId` is in scope at line 142 (`form.watch('cluster_id')`); `target` is in scope at line 143; `querySetId` at line 144. All three available for the `useJudgmentLists` filter. ✅
+
+11. **Persistence scope consistency:** N/A — no localStorage/sessionStorage.
+
+12. **Enumerated value contract audit:** N/A — no new `` allowlists. The `?target=` wire param is a free-form bounded string, not an enum.
+
+13. **Audit-event coverage:** N/A — pre-MVP2 (audit_log not yet active).
+
+---
+
+## 12) Definition of plan done
+
+- [ ] Every FR is mapped to stories/tasks/tests/docs updates. ✅
+- [ ] Every story includes New files, Modified files, Endpoints, Key interfaces, Tasks, and DoD. ✅
+- [ ] Test layers (unit/integration/contract/e2e) are explicitly scoped. ✅
+- [ ] Documentation updates across docs/01-05 are planned and owned. ✅
+- [ ] Lean refactor scope is "none" with stated reason. ✅
+- [ ] Epic gates are measurable. ✅
+- [ ] Story-by-Story Verification Gate is included. ✅
+- [ ] Plan consistency review (§11) performed with no unresolved findings. ✅
diff --git a/docs/02_product/planned_features/feat_study_target_judgment_mismatch_guard/pipeline_status.md b/docs/02_product/planned_features/feat_study_target_judgment_mismatch_guard/pipeline_status.md
new file mode 100644
index 00000000..224f592a
--- /dev/null
+++ b/docs/02_product/planned_features/feat_study_target_judgment_mismatch_guard/pipeline_status.md
@@ -0,0 +1,25 @@
+# Pipeline Status — feat_study_target_judgment_mismatch_guard
+
+## Idea
+- Status: Complete
+- File: idea.md
+- Preflight: applied 2026-05-21 (5 patches across idea.md — Locked decisions section added, capability gaps closed, line-ref drift fixed)
+
+## Spec
+- Status: Approved
+- Date: 2026-05-21
+- File: feature_spec.md
+- Cross-model review: GPT-5.5 — 3 cycles (10 + 4 + 4 findings raised; 17 accepted + 1 rejected with cited counter-evidence at `create-study-modal.tsx:508`)
+- Phases: 1 (single-phase ship; no deferred phases)
+- Major changes vs idea: added FR-1b `JUDGMENT_CLUSTER_MISMATCH` cross-check (cycle-1 High finding closed the cross-cluster judgment-list reuse gap); +2 ACs (AC-11 + AC-12); +1 follow-up boundary documented (`bug_studies_query_set_cluster_consistency`).
+
+## Plan
+- Status: Approved
+- Date: 2026-05-21
+- File: implementation_plan.md
+- Cross-model review: GPT-5.5 — 3 cycles (12 + 3 + 1 findings raised; **all 16 accepted, 0 rejected**)
+- Stories: 3 across 1 epic (1.1 backend listing surface, 1.2 backend validators, 2.1 frontend filter + cascade + empty-state)
+- Phases covered: single phase
+
+## Implementation
+- Status: Not started
diff --git a/ui/src/__tests__/components/studies/create-study-modal.target-filter.test.tsx b/ui/src/__tests__/components/studies/create-study-modal.target-filter.test.tsx
new file mode 100644
index 00000000..9e81f46b
--- /dev/null
+++ b/ui/src/__tests__/components/studies/create-study-modal.target-filter.test.tsx
@@ -0,0 +1,352 @@
+/**
+ * feat_study_target_judgment_mismatch_guard Story 2.1 — create-study modal
+ * Step-2 judgment-list dropdown filters by the Step-1 target via the new
+ * `?target=` wire param, and cascade-resets `judgment_list_id` on target /
+ * cluster change. Empty-state copy surfaces the target value + CTA to
+ * /judgments.
+ *
+ * Test inventory (plan §3.5, 5 vitest cases):
+ * 1. Modal calls useJudgmentLists with { query_set_id, cluster_id, target,
+ * limit: 200 } — verifies the component DELEGATES filtering to the wire
+ * (NOT client-side filtering, which the spec's anti-patterns forbid).
+ * 2a. target change via the DROPDOWN resets judgment_list_id.
+ * 2b. target change via the MANUAL resets judgment_list_id.
+ * 3. cluster_id change resets judgment_list_id (regression-lock for the
+ * pre-existing cascade at create-study-modal.tsx:509).
+ * 4. Empty-state copy renders with the exact target value substituted +
+ * CTA href="/judgments" when the judgment-lists endpoint returns
+ * `{ data: [], next_cursor: null, has_more: false }`.
+ */
+import { http, HttpResponse } from 'msw';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { type ReactNode } from 'react';
+
+import { TooltipProvider } from '@/components/ui/tooltip';
+
+import { server } from '../../setup';
+
+// Native-select shim — same escape hatch the sibling modal tests use so
+// Radix's portal handling doesn't crash jsdom.
+vi.mock('@/components/ui/select', async () => {
+ const { mockShadcnSelect } = await import('../../helpers/shadcn-select-mock');
+ return mockShadcnSelect();
+});
+
+const { CreateStudyModal } = await import('@/components/studies/create-study-modal');
+
+const API_BASE = 'http://api.test';
+
+function wrap(node: ReactNode) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+ {node}
+ ,
+ );
+}
+
+interface JudgmentListsCallSpy {
+ calls: URL[];
+}
+
+/**
+ * Mock the full Step-1 + Step-2 cluster + target + query-set + judgment-list
+ * surface, with a `judgmentListsSpy` that captures every `?target=` /
+ * `?cluster_id=` / `?query_set_id=` filter the modal passes.
+ */
+function setupBackend(opts: {
+ judgmentListsData?: Array<{
+ id: string;
+ name: string;
+ target: string;
+ cluster_id: string;
+ query_set_id: string;
+ }>;
+}): JudgmentListsCallSpy {
+ const spy: JudgmentListsCallSpy = { calls: [] };
+ const summaries = (opts.judgmentListsData ?? []).map((jl) => ({
+ id: jl.id,
+ name: jl.name,
+ description: null,
+ query_set_id: jl.query_set_id,
+ cluster_id: jl.cluster_id,
+ target: jl.target,
+ status: 'complete',
+ created_at: '2026-05-21T00:00:00Z',
+ }));
+
+ server.use(
+ http.get(`${API_BASE}/api/v1/clusters`, () =>
+ HttpResponse.json(
+ {
+ data: [
+ {
+ id: 'c1',
+ name: 'cluster-one',
+ engine_type: 'elasticsearch',
+ environment: 'dev',
+ base_url: 'http://localhost:9200',
+ auth_kind: 'es_apikey',
+ created_at: '2026-05-21T00:00:00Z',
+ health_check: {
+ status: 'green',
+ version: '9.4.0',
+ checked_at: '2026-05-21T00:00:00Z',
+ error: null,
+ },
+ },
+ {
+ id: 'c2',
+ name: 'cluster-two',
+ engine_type: 'elasticsearch',
+ environment: 'dev',
+ base_url: 'http://localhost:9201',
+ auth_kind: 'es_apikey',
+ created_at: '2026-05-21T00:00:00Z',
+ health_check: {
+ status: 'green',
+ version: '9.4.0',
+ checked_at: '2026-05-21T00:00:00Z',
+ error: null,
+ },
+ },
+ ],
+ next_cursor: null,
+ has_more: false,
+ },
+ { headers: { 'X-Total-Count': '2' } },
+ ),
+ ),
+ http.get(`${API_BASE}/api/v1/clusters/:id/schema`, () => HttpResponse.json({ fields: [] })),
+ http.get(`${API_BASE}/api/v1/clusters/:id/targets`, () =>
+ HttpResponse.json({
+ data: [
+ { name: 'products', doc_count: 42 },
+ { name: 'articles', doc_count: 12 },
+ ],
+ }),
+ ),
+ http.get(`${API_BASE}/api/v1/query-sets`, () =>
+ HttpResponse.json(
+ {
+ data: [
+ { id: 'qs1', name: 'demo-qs', cluster_id: 'c1', created_at: '2026-05-21T00:00:00Z' },
+ ],
+ next_cursor: null,
+ has_more: false,
+ },
+ { headers: { 'X-Total-Count': '1' } },
+ ),
+ ),
+ http.get(`${API_BASE}/api/v1/judgment-lists`, ({ request }) => {
+ spy.calls.push(new URL(request.url));
+ return HttpResponse.json(
+ { data: summaries, next_cursor: null, has_more: false },
+ { headers: { 'X-Total-Count': String(summaries.length) } },
+ );
+ }),
+ http.get(`${API_BASE}/api/v1/query-templates`, () =>
+ HttpResponse.json(
+ { data: [], next_cursor: null, has_more: false },
+ { headers: { 'X-Total-Count': '0' } },
+ ),
+ ),
+ );
+ return spy;
+}
+
+async function pickCluster(value: 'c1' | 'c2') {
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: /cluster-one/ })).toBeInTheDocument(),
+ );
+ fireEvent.change(screen.getByLabelText('Cluster'), { target: { value } });
+}
+
+async function pickTargetDropdown(value: 'products' | 'articles') {
+ await waitFor(() =>
+ expect(screen.queryAllByRole('option', { name: new RegExp(value) }).length).toBeGreaterThan(0),
+ );
+ fireEvent.change(screen.getByLabelText('Target index / collection'), { target: { value } });
+}
+
+async function advanceToStep2() {
+ fireEvent.click(screen.getByTestId('step-next'));
+ await waitFor(() => expect(screen.getByTestId('step-2')).toBeInTheDocument());
+}
+
+async function pickQuerySet() {
+ await waitFor(() =>
+ expect(screen.queryAllByRole('option', { name: /demo-qs/ }).length).toBeGreaterThan(0),
+ );
+ fireEvent.change(screen.getByLabelText('Query set'), { target: { value: 'qs1' } });
+}
+
+async function pickJudgmentList(value: string) {
+ await waitFor(() =>
+ expect(screen.queryAllByRole('option', { name: value }).length).toBeGreaterThan(0),
+ );
+ fireEvent.change(screen.getByLabelText('Judgment list'), { target: { value } });
+}
+
+function findLastJudgmentListsCall(spy: JudgmentListsCallSpy): URL {
+ expect(spy.calls.length).toBeGreaterThan(0);
+ return spy.calls[spy.calls.length - 1]!;
+}
+
+// --- Test cases ---------------------------------------------------------
+
+describe('CreateStudyModal Step-2 judgment-list dropdown — target-aware filter', () => {
+ it('passes target + cluster_id + query_set_id to useJudgmentLists (Case 1)', async () => {
+ const spy = setupBackend({
+ judgmentListsData: [
+ {
+ id: 'jl-prod',
+ name: 'jl-for-products',
+ target: 'products',
+ cluster_id: 'c1',
+ query_set_id: 'qs1',
+ },
+ ],
+ });
+ wrap( {}} />);
+ await pickCluster('c1');
+ await pickTargetDropdown('products');
+ await advanceToStep2();
+ await pickQuerySet();
+ // Wait for the judgment-lists hook to fire after Step-2 query_set pick.
+ await waitFor(() => {
+ const last = findLastJudgmentListsCall(spy);
+ expect(last.searchParams.get('target')).toBe('products');
+ expect(last.searchParams.get('cluster_id')).toBe('c1');
+ expect(last.searchParams.get('query_set_id')).toBe('qs1');
+ expect(last.searchParams.get('limit')).toBe('200');
+ });
+ });
+
+ it('cascades target dropdown change → judgment_list_id reset (Case 2a, AC-9)', async () => {
+ const spy = setupBackend({
+ judgmentListsData: [
+ {
+ id: 'jl-prod',
+ name: 'jl-for-products',
+ target: 'products',
+ cluster_id: 'c1',
+ query_set_id: 'qs1',
+ },
+ {
+ id: 'jl-art',
+ name: 'jl-for-articles',
+ target: 'articles',
+ cluster_id: 'c1',
+ query_set_id: 'qs1',
+ },
+ ],
+ });
+ wrap( {}} />);
+ await pickCluster('c1');
+ await pickTargetDropdown('products');
+ await advanceToStep2();
+ await pickQuerySet();
+ await pickJudgmentList('jl-for-products');
+ // The Step-2 advance gate at create-study-modal.tsx:386 requires
+ // judgment_list_id to be set. We assert state via the gate behavior:
+ // if the cascade reset fires correctly, the Next button is disabled
+ // after we change target back to articles.
+ fireEvent.click(screen.getByText(/Back/));
+ await waitFor(() => expect(screen.getByTestId('step-1')).toBeInTheDocument());
+ await pickTargetDropdown('articles');
+ fireEvent.click(screen.getByTestId('step-next'));
+ await waitFor(() => expect(screen.getByTestId('step-2')).toBeInTheDocument());
+ // judgment_list_id should be '' — Next is disabled until we re-pick.
+ const nextBtn = screen.getByTestId('step-next');
+ expect(nextBtn).toBeDisabled();
+ // Spy should now show the latest hook call uses ?target=articles.
+ await waitFor(() => {
+ const last = findLastJudgmentListsCall(spy);
+ expect(last.searchParams.get('target')).toBe('articles');
+ });
+ });
+
+ it('cascades target manual-input change → judgment_list_id reset (Case 2b, AC-9)', async () => {
+ setupBackend({
+ judgmentListsData: [
+ {
+ id: 'jl-prod',
+ name: 'jl-for-products',
+ target: 'products',
+ cluster_id: 'c1',
+ query_set_id: 'qs1',
+ },
+ ],
+ });
+ wrap( {}} />);
+ await pickCluster('c1');
+ await pickTargetDropdown('products');
+ await advanceToStep2();
+ await pickQuerySet();
+ await pickJudgmentList('jl-for-products');
+ fireEvent.click(screen.getByText(/Back/));
+ await waitFor(() => expect(screen.getByTestId('step-1')).toBeInTheDocument());
+ // Switch to manual mode and edit the input — the registered RHF onChange
+ // PLUS the new cascade reset must both fire.
+ fireEvent.click(screen.getByRole('button', { name: 'Enter manually' }));
+ await waitFor(() => expect(screen.getByPlaceholderText('products')).toBeInTheDocument());
+ fireEvent.change(screen.getByPlaceholderText('products'), {
+ target: { value: 'custom-target' },
+ });
+ fireEvent.click(screen.getByTestId('step-next'));
+ await waitFor(() => expect(screen.getByTestId('step-2')).toBeInTheDocument());
+ // Step-2 advance gated until we re-pick a judgment list.
+ expect(screen.getByTestId('step-next')).toBeDisabled();
+ });
+
+ it('cascades cluster change → judgment_list_id reset (Case 3, AC-12 regression-lock)', async () => {
+ setupBackend({
+ judgmentListsData: [
+ {
+ id: 'jl-prod',
+ name: 'jl-for-products',
+ target: 'products',
+ cluster_id: 'c1',
+ query_set_id: 'qs1',
+ },
+ ],
+ });
+ wrap( {}} />);
+ await pickCluster('c1');
+ await pickTargetDropdown('products');
+ await advanceToStep2();
+ await pickQuerySet();
+ await pickJudgmentList('jl-for-products');
+ fireEvent.click(screen.getByText(/Back/));
+ await waitFor(() => expect(screen.getByTestId('step-1')).toBeInTheDocument());
+ // Existing cluster-change handler at line 502-514 resets target +
+ // judgment_list_id + query_set_id + template_id + manualMode. This
+ // test locks that behavior against regression.
+ fireEvent.change(screen.getByLabelText('Cluster'), { target: { value: 'c2' } });
+ // Step-1 advance gate fails until we re-pick target.
+ expect(screen.getByTestId('step-next')).toBeDisabled();
+ });
+
+ it('renders empty-state copy with target value and /judgments CTA when filter returns no matches (Case 4, AC-8)', async () => {
+ setupBackend({ judgmentListsData: [] });
+ wrap( {}} />);
+ await pickCluster('c1');
+ await pickTargetDropdown('articles');
+ await advanceToStep2();
+ await pickQuerySet();
+ // Empty-state message should mention the target value verbatim.
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ /No judgment lists for target "articles" on this cluster \+ query set\. Generate a new one from \/judgments\./,
+ ),
+ ).toBeInTheDocument();
+ });
+ // CTA link points at /judgments.
+ const cta = screen.getByRole('link', { name: 'Generate judgments' });
+ expect(cta).toHaveAttribute('href', '/judgments');
+ });
+});
diff --git a/ui/src/components/studies/create-study-modal.tsx b/ui/src/components/studies/create-study-modal.tsx
index 49659813..158d81a6 100644
--- a/ui/src/components/studies/create-study-modal.tsx
+++ b/ui/src/components/studies/create-study-modal.tsx
@@ -145,6 +145,13 @@ export function CreateStudyModal({ open, onOpenChange }: CreateStudyModalProps)
const templateId = form.watch('template_id');
const metric = form.watch('metric');
+ // feat_study_target_judgment_mismatch_guard FR-4: hoist the RHF
+ // registration for `target` so the manual-mode can keep name/ref/
+ // onBlur/validate wiring AND tack the judgment_list_id cascade reset onto
+ // onChange without an IIFE in JSX. Used at the Step-1 manual-mode branch
+ // below.
+ const targetReg = form.register('target');
+
const clusters = useClusters({ limit: 200 });
const selectedCluster = clusters.data?.data.find((c) => c.id === clusterId);
const schema = useClusterSchema(clusterId, target || undefined);
@@ -187,8 +194,15 @@ export function CreateStudyModal({ open, onOpenChange }: CreateStudyModalProps)
} as typeof targets;
const querySets = useQuerySets({ cluster_id: clusterId || undefined, limit: 200 });
+ // feat_study_target_judgment_mismatch_guard FR-4: filter judgment-lists by
+ // both the chosen cluster AND the chosen target so the dropdown only shows
+ // valid pairings. The backend's POST /studies validators (FR-1 + FR-1b) are
+ // the contract; this client-side filter is the UX prefetch so the operator
+ // can't even submit a mismatch.
const judgmentLists = useJudgmentLists({
query_set_id: querySetId || undefined,
+ cluster_id: clusterId || undefined,
+ target: target || undefined,
limit: 200,
});
const templates = useTemplates({
@@ -523,8 +537,22 @@ export function CreateStudyModal({ open, onOpenChange }: CreateStudyModalProps)
{manualMode ? (
// Manual-mode fallback (preserves the original Input behavior).
+ // feat_study_target_judgment_mismatch_guard FR-4: uses the
+ // hoisted `targetReg` so RHF's name/ref/onBlur/validate
+ // wiring stays intact while onChange ALSO cascade-resets
+ // judgment_list_id (mirror of line ~596 query-set pattern).
+ // Do NOT replace with bare value/onChange — that breaks
+ // RHF dirty/touched/validation state.
<>
-
+ {
+ targetReg.onChange(e);
+ form.setValue('judgment_list_id', '');
+ }}
+ placeholder="products"
+ />
{targets.isError && targets.error?.errorCode === 'TARGETS_FORBIDDEN' && (