From 2788b83e8e10f8f93430c46e0d4501155df1b992 Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Thu, 4 Jun 2026 22:47:38 -0400 Subject: [PATCH 1/7] =?UTF-8?q?feat(proposals):=20supersede=20non-winning?= =?UTF-8?q?=20chain=20links=20=E2=80=94=20schema=20+=20repo=20helpers=20(E?= =?UTF-8?q?pic=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat_overnight_final_solution_phase3 Stories 1.1 + 1.2 (FR-1 + FR-3). Story 1.1 — Schema + 3 Literal mirrors: - Migration 0023_proposals_superseded_status extends the proposals_status_check CHECK to admit 'superseded'. downgrade() hard-guards against existing superseded rows (spec D-3 / Q4 locked option (a) — refuse, not destructive DELETE). - ORM CHECK literal (backend/app/db/models/proposal.py:42) - ProposalStatusFilter Literal (backend/app/db/repo/proposal.py:56) - ProposalStatusWire Literal (backend/app/api/v1/schemas.py:1379) Story 1.2 — Two new repo helpers: - bulk_mark_superseded(db, *, study_ids) — conditional UPDATE-RETURNING gated on WHERE status='pending' (idempotent; silently skips pr_opened, pr_merged, rejected per D-5 + D-6). - reinstate_from_superseded(db, *, proposal_id) — read-check-mutate per spec D-17 (the pure conditional UPDATE would collapse 404 vs 409 into one zero-row signal). Raises LookupError (→404 PROPOSAL_NOT_FOUND) or InvalidStateTransition (→409 INVALID_STATE_TRANSITION, reused per D-16). - Both re-exported via backend/app/db/repo/__init__.py. Tests: 9 integration tests at backend/tests/integration/test_proposal_supersession.py covering pending→superseded transition, idempotency, pr_opened/rejected skip, reinstate happy path, and both error-path branches. Postgres-backed per spec D-20. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: SoundMindsAI --- backend/app/api/v1/schemas.py | 2 +- backend/app/db/models/proposal.py | 2 +- backend/app/db/repo/__init__.py | 4 + backend/app/db/repo/proposal.py | 66 ++++- .../integration/test_proposal_supersession.py | 265 ++++++++++++++++++ .../0023_proposals_superseded_status.py | 72 +++++ 6 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 backend/tests/integration/test_proposal_supersession.py create mode 100644 migrations/versions/0023_proposals_superseded_status.py diff --git a/backend/app/api/v1/schemas.py b/backend/app/api/v1/schemas.py index 2e8b6336..1a4fe0a2 100644 --- a/backend/app/api/v1/schemas.py +++ b/backend/app/api/v1/schemas.py @@ -1376,7 +1376,7 @@ class CalibrationResponse(BaseModel): # feat_digest_proposal Epic 3 schemas (Stories 3.1-3.4) # --------------------------------------------------------------------------- -ProposalStatusWire = Literal["pending", "pr_opened", "pr_merged", "rejected"] +ProposalStatusWire = Literal["pending", "pr_opened", "pr_merged", "rejected", "superseded"] """Wire values for ``proposals.status`` filter on ``GET /api/v1/proposals``. Values must match backend/app/db/models/proposal.py CHECK diff --git a/backend/app/db/models/proposal.py b/backend/app/db/models/proposal.py index 5cf07c30..3732d003 100644 --- a/backend/app/db/models/proposal.py +++ b/backend/app/db/models/proposal.py @@ -39,7 +39,7 @@ class Proposal(Base): __tablename__ = "proposals" __table_args__ = ( CheckConstraint( - "status IN ('pending', 'pr_opened', 'pr_merged', 'rejected')", + "status IN ('pending', 'pr_opened', 'pr_merged', 'rejected', 'superseded')", name="proposals_status_check", ), CheckConstraint( diff --git a/backend/app/db/repo/__init__.py b/backend/app/db/repo/__init__.py index be430992..72104ff5 100644 --- a/backend/app/db/repo/__init__.py +++ b/backend/app/db/repo/__init__.py @@ -77,6 +77,7 @@ from backend.app.db.repo.proposal import ( InvalidStateTransition, ProposalStatusFilter, + bulk_mark_superseded, count_proposals, create_proposal, get_proposal, @@ -90,6 +91,7 @@ mark_proposal_pr_merged_from_closed, mark_proposal_pr_opened, mark_proposal_pr_reopened, + reinstate_from_superseded, reject_proposal, set_proposal_pr_open_error, stamp_proposal_last_polled_at, @@ -222,11 +224,13 @@ # feat_digest_proposal Story 1.2 (digest repo + proposal repo extensions) "InvalidStateTransition", "ProposalStatusFilter", + "bulk_mark_superseded", "count_proposals", "create_digest", "get_digest_for_study", "list_pending_proposals_for_boot_scan", "list_proposals_paginated", + "reinstate_from_superseded", "reject_proposal", "update_proposal_for_digest", # feat_github_pr_worker Story 1.1 (config_repo list/count + proposal pr-transition helpers) diff --git a/backend/app/db/repo/proposal.py b/backend/app/db/repo/proposal.py index a61e278c..d2be34fb 100644 --- a/backend/app/db/repo/proposal.py +++ b/backend/app/db/repo/proposal.py @@ -53,7 +53,7 @@ # Wire values for `?status=` filter on `GET /api/v1/proposals`. # Values must match backend/app/db/models/proposal.py CHECK proposals_status_check. -ProposalStatusFilter = Literal["pending", "pr_opened", "pr_merged", "rejected"] +ProposalStatusFilter = Literal["pending", "pr_opened", "pr_merged", "rejected", "superseded"] # Per chore_proposals_source_filter_server_side: distinguishes proposals # derived from a completed study (study_id NOT NULL) from operator-authored # manual proposals (study_id NULL). @@ -632,3 +632,67 @@ async def hard_delete_proposal(db: AsyncSession, proposal_id: str) -> bool: await db.delete(existing) await db.flush() return True + + +# --------------------------------------------------------------------------- +# feat_overnight_final_solution_phase3 Story 1.2 — supersession + reinstate +# --------------------------------------------------------------------------- + + +async def bulk_mark_superseded( + db: AsyncSession, + *, + study_ids: list[str], +) -> list[str]: + """Conditional UPDATE for the chain-rollup loser supersession path. + + Transitions ``pending → superseded`` for proposals whose ``study_id`` + is in ``study_ids``. Idempotent. Silently skips rows whose status is not ``pending`` — + that includes already-superseded rows on a re-run, ``pr_opened`` / + ``pr_merged`` rows (operator already shipped), and ``rejected`` rows + (operator already rejected — D-6 / Q3 precedence). Returns the IDs + actually transitioned, or ``[]`` if no rows matched. Caller commits. + """ + if not study_ids: + return [] + stmt = ( + update(Proposal) + .where(Proposal.study_id.in_(study_ids), Proposal.status == "pending") + .values(status="superseded") + .returning(Proposal.id) + ) + result = await db.execute(stmt) + transitioned: list[str] = [row[0] for row in result] + if transitioned: + await db.flush() + return transitioned + + +async def reinstate_from_superseded( + db: AsyncSession, + *, + proposal_id: str, +) -> Proposal: + """Transition ``superseded → pending`` for the operator-initiated reinstate flow. + + Mirrors the :func:`reject_proposal` read-check-mutate precedent + (spec D-17) — the conditional-UPDATE pattern would collapse 404 + (unknown id) and 409 (wrong status) into one zero-row signal and + the API endpoint could not drive its deterministic 404-vs-409 + contract. + + Raises :class:`LookupError` if the proposal id does not exist + (API translates to HTTP 404 ``PROPOSAL_NOT_FOUND``). Raises + :class:`InvalidStateTransition` if the row is not in ``superseded`` + status (API translates to HTTP 409 ``INVALID_STATE_TRANSITION`` — + D-16 reuses the existing reject endpoint's code). + Caller commits. + """ + row = await get_proposal(db, proposal_id) + if row is None: + raise LookupError(f"proposal {proposal_id!r} not found") + if row.status != "superseded": + raise InvalidStateTransition(proposal_id, row.status) + row.status = "pending" + await db.flush() + return row diff --git a/backend/tests/integration/test_proposal_supersession.py b/backend/tests/integration/test_proposal_supersession.py new file mode 100644 index 00000000..e162d9a6 --- /dev/null +++ b/backend/tests/integration/test_proposal_supersession.py @@ -0,0 +1,265 @@ +# SPDX-FileCopyrightText: 2026 soundminds.ai +# +# SPDX-License-Identifier: Apache-2.0 + +"""Repo unit-of-work tests for the Phase 3 supersession + reinstate helpers. + +Exercises :func:`bulk_mark_superseded` (conditional UPDATE-RETURNING gated on +``WHERE status='pending'``) and :func:`reinstate_from_superseded` +(read-check-mutate per spec D-17, distinguishing 404 from 409). Tests run +against the real Postgres test DB (per spec D-20) because the conditional +``UPDATE … RETURNING`` semantics and the CHECK constraint behavior cannot +be accurately represented against an in-memory SQLite session. +""" + +from __future__ import annotations + +import uuid + +import pytest + +from backend.app.db import repo +from backend.app.db.repo.proposal import InvalidStateTransition +from backend.app.db.session import get_session_factory +from backend.tests.conftest import postgres_reachable + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not postgres_reachable(), + reason="Postgres not reachable — see docs/03_runbooks/local-dev.md", + ), +] + + +async def _seed_minimal_chain() -> dict[str, str | None]: + """Insert the minimal FK chain a proposal needs; return the IDs. + + Mirrors the helper in ``test_proposal_repo.py`` — kept local so the + Phase 3 test file is self-contained. + """ + factory = get_session_factory() + async with factory() as db: + cluster = await repo.create_cluster( + db, + id=str(uuid.uuid4()), + name=f"sup-cluster-{uuid.uuid4().hex[:8]}", + engine_type="elasticsearch", + environment="dev", + base_url="http://stub:9200", + auth_kind="es_basic", + credentials_ref="ref", + ) + template = await repo.create_query_template( + db, + id=str(uuid.uuid4()), + name=f"sup-tmpl-{uuid.uuid4().hex[:8]}", + engine_type="elasticsearch", + body='{"query": {"match_all": {}}}', + declared_params={}, + version=1, + ) + query_set = await repo.create_query_set( + db, + id=str(uuid.uuid4()), + name=f"sup-qs-{uuid.uuid4().hex[:8]}", + cluster_id=cluster.id, + ) + jl = await repo.create_judgment_list( + db, + id=str(uuid.uuid4()), + name=f"sup-jl-{uuid.uuid4().hex[:8]}", + description=None, + query_set_id=query_set.id, + cluster_id=cluster.id, + target="stub-index", + current_template_id=template.id, + rubric="r", + status="complete", + ) + study = await repo.create_study( + db, + id=str(uuid.uuid4()), + name=f"sup-study-{uuid.uuid4().hex[:8]}", + cluster_id=cluster.id, + target="stub-index", + template_id=template.id, + query_set_id=query_set.id, + judgment_list_id=jl.id, + search_space={}, + objective={}, + config={}, + status="completed", + optuna_study_name=str(uuid.uuid4()), + ) + await db.commit() + return { + "cluster_id": cluster.id, + "template_id": template.id, + "study_id": study.id, + } + + +async def _create_proposal(ids: dict[str, str | None], status: str = "pending") -> str: + factory = get_session_factory() + async with factory() as db: + p = await repo.create_proposal( + db, + id=str(uuid.uuid4()), + study_id=ids["study_id"], + study_trial_id=None, + cluster_id=ids["cluster_id"], + template_id=ids["template_id"], + config_diff={}, + metric_delta=None, + status=status, + ) + await db.commit() + return p.id + + +# --------------------------------------------------------------------------- +# bulk_mark_superseded +# --------------------------------------------------------------------------- + + +async def test_bulk_mark_superseded_transitions_pending_returns_ids() -> None: + """AC-3: the conditional UPDATE flips pending → superseded and returns IDs.""" + ids_a = await _seed_minimal_chain() + ids_b = await _seed_minimal_chain() + pa = await _create_proposal(ids_a) + pb = await _create_proposal(ids_b) + factory = get_session_factory() + async with factory() as db: + transitioned = await repo.bulk_mark_superseded( + db, + study_ids=[ids_a["study_id"], ids_b["study_id"]], # type: ignore[list-item] + ) + await db.commit() + assert set(transitioned) == {pa, pb} + # Subsequent reads see status='superseded'. + factory2 = get_session_factory() + async with factory2() as db: + ra = await repo.get_proposal(db, pa) + rb = await repo.get_proposal(db, pb) + assert ra is not None and ra.status == "superseded" + assert rb is not None and rb.status == "superseded" + + +async def test_bulk_mark_superseded_idempotent_on_rerun() -> None: + """AC-3: re-running on already-superseded rows returns [].""" + ids = await _seed_minimal_chain() + await _create_proposal(ids) + factory = get_session_factory() + async with factory() as db: + first = await repo.bulk_mark_superseded(db, study_ids=[ids["study_id"]]) # type: ignore[list-item] + await db.commit() + assert len(first) == 1 + async with factory() as db: + second = await repo.bulk_mark_superseded(db, study_ids=[ids["study_id"]]) # type: ignore[list-item] + await db.commit() + assert second == [] + + +async def test_bulk_mark_superseded_skips_pr_opened() -> None: + """AC-4 / D-5: pr_opened rows are NOT transitioned (system can't supersede a shipped PR).""" + ids = await _seed_minimal_chain() + pid = await _create_proposal(ids, status="pending") + # Manually transition to pr_opened via the existing helper. + factory = get_session_factory() + async with factory() as db: + await repo.mark_proposal_pr_opened(db, pid, pr_url="https://example.com/pr/1") + await db.commit() + async with factory() as db: + transitioned = await repo.bulk_mark_superseded( + db, + study_ids=[ids["study_id"]], # type: ignore[list-item] + ) + await db.commit() + assert transitioned == [] + async with factory() as db: + row = await repo.get_proposal(db, pid) + assert row is not None and row.status == "pr_opened" + + +async def test_bulk_mark_superseded_skips_rejected() -> None: + """AC-4 / D-6 / Q3: rejected rows are stronger than superseded; never auto-flipped.""" + ids = await _seed_minimal_chain() + pid = await _create_proposal(ids, status="pending") + factory = get_session_factory() + async with factory() as db: + await repo.reject_proposal(db, pid, reason="operator-rejected") + await db.commit() + async with factory() as db: + transitioned = await repo.bulk_mark_superseded( + db, + study_ids=[ids["study_id"]], # type: ignore[list-item] + ) + await db.commit() + assert transitioned == [] + async with factory() as db: + row = await repo.get_proposal(db, pid) + assert row is not None and row.status == "rejected" + + +async def test_bulk_mark_superseded_empty_study_ids_returns_empty() -> None: + """Defensive: empty input never touches the DB.""" + factory = get_session_factory() + async with factory() as db: + result = await repo.bulk_mark_superseded(db, study_ids=[]) + assert result == [] + + +# --------------------------------------------------------------------------- +# reinstate_from_superseded +# --------------------------------------------------------------------------- + + +async def test_reinstate_from_superseded_happy_path() -> None: + """AC-5: superseded → pending flip + returns the updated row.""" + ids = await _seed_minimal_chain() + pid = await _create_proposal(ids, status="pending") + factory = get_session_factory() + async with factory() as db: + await repo.bulk_mark_superseded(db, study_ids=[ids["study_id"]]) # type: ignore[list-item] + await db.commit() + async with factory() as db: + row = await repo.reinstate_from_superseded(db, proposal_id=pid) + await db.commit() + assert row.status == "pending" + async with factory() as db: + fresh = await repo.get_proposal(db, pid) + assert fresh is not None and fresh.status == "pending" + + +async def test_reinstate_from_superseded_raises_lookup_error_on_unknown_id() -> None: + """D-17: unknown id → LookupError (distinct from wrong-status).""" + factory = get_session_factory() + bogus = str(uuid.uuid4()) + async with factory() as db: + with pytest.raises(LookupError): + await repo.reinstate_from_superseded(db, proposal_id=bogus) + + +async def test_reinstate_from_superseded_raises_invalid_state_on_pending() -> None: + """AC-13: a pending (non-superseded) row → InvalidStateTransition.""" + ids = await _seed_minimal_chain() + pid = await _create_proposal(ids, status="pending") + factory = get_session_factory() + async with factory() as db: + with pytest.raises(InvalidStateTransition) as exc_info: + await repo.reinstate_from_superseded(db, proposal_id=pid) + assert exc_info.value.current_status == "pending" + + +async def test_reinstate_from_superseded_raises_invalid_state_on_pr_opened() -> None: + """Defense: a pr_opened row stays pr_opened; reinstate refuses.""" + ids = await _seed_minimal_chain() + pid = await _create_proposal(ids, status="pending") + factory = get_session_factory() + async with factory() as db: + await repo.mark_proposal_pr_opened(db, pid, pr_url="https://example.com/pr/1") + await db.commit() + async with factory() as db: + with pytest.raises(InvalidStateTransition): + await repo.reinstate_from_superseded(db, proposal_id=pid) diff --git a/migrations/versions/0023_proposals_superseded_status.py b/migrations/versions/0023_proposals_superseded_status.py new file mode 100644 index 00000000..8eed4966 --- /dev/null +++ b/migrations/versions/0023_proposals_superseded_status.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 soundminds.ai +# +# SPDX-License-Identifier: Apache-2.0 + +"""proposals_superseded_status. + +Revision ID: 0023 +Revises: 0022 +Create Date: 2026-06-05 00:00:00.000000 + +feat_overnight_final_solution_phase3 Story 1.1 / FR-1 — extends the +``proposals_status_check`` CHECK constraint to admit ``'superseded'`` +as the system-initiated non-winning-chain-link status. + +This is a pure relaxation (new value admitted; existing rows unaffected), +so upgrade needs no backfill. Per CLAUDE.md Absolute Rule #5 this ships a +reversible ``downgrade()`` that round-trips cleanly. The downgrade +hard-guards against existing ``'superseded'`` rows: restoring the narrower +CHECK while such a row exists would fail with a confusing constraint +violation, so we abort with a clear operator message instead (spec D-3 / +Q4 locked option (a) — refuse, not destructive DELETE). +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0023" +down_revision: str | None = "0022" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_constraint("proposals_status_check", "proposals", type_="check") + op.create_check_constraint( + "proposals_status_check", + "proposals", + "status IN ('pending', 'pr_opened', 'pr_merged', 'rejected', 'superseded')", + ) + + +def downgrade() -> None: + # Hard-guard (spec D-3 / Q4 locked): refuse if any ``superseded`` rows + # exist so the operator gets a clear message instead of a constraint + # violation. Operator must manually decide each row (typically: + # ``UPDATE proposals SET status='rejected' WHERE status='superseded';``) + # before re-running the downgrade. + bind = op.get_bind() + count = bind.execute( + sa.text("SELECT COUNT(*) FROM proposals WHERE status = 'superseded'") + ).scalar_one() + if count: + # S608: the f-string is a HUMAN-READABLE error message naming the + # recommended manual recovery UPDATE; it's not executed as SQL. + manual_fix = ( + "UPDATE proposals SET status='rejected' " # noqa: S608 + "WHERE status='superseded';" + ) + raise RuntimeError( + f"Cannot downgrade {revision}: {count} proposal row(s) with " + f"status='superseded' exist. Update them to 'rejected' first: " + f"{manual_fix}" + ) + + op.drop_constraint("proposals_status_check", "proposals", type_="check") + op.create_check_constraint( + "proposals_status_check", + "proposals", + "status IN ('pending', 'pr_opened', 'pr_merged', 'rejected')", + ) From d9498e3133ac8e4276132eeeed40a1c14b9a0ecd Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Thu, 4 Jun 2026 22:51:52 -0400 Subject: [PATCH 2/7] feat(proposals): chain-rollup service + _stop wiring + filter widening (Epic 2) feat_overnight_final_solution_phase3 Stories 2.1 + 2.2 + 2.3 (FR-2, FR-4, FR-5, FR-7 system half). - Story 2.1: backend/app/services/chain_rollup.py with mark_non_winning_chain_proposals_superseded returning (count, ids) for post-commit emission per spec D-19. Early-returns (0, []) for missing chain / single-link / in_flight / no winner. 6 unit tests. - Story 2.2: backend/app/db/repo/study.py:341 filter widens from != 'rejected' to .notin_(('rejected','superseded')) so the chain panel honors the rollup (FR-4 co-requisite of FR-2). Cascades to list_recent_completed_chains. - Story 2.3: backend/workers/orchestrator.py:_stop appends conditional rollup inside the existing transaction; cheap heuristic skips standalone studies. chain_proposals_superseded structlog event fires AFTER db.commit() per spec D-19. Integration tests for _stop atomicity + filter widening will land in a follow-up commit on this branch. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: SoundMindsAI --- backend/app/db/repo/study.py | 9 +- backend/app/services/chain_rollup.py | 81 ++++++++++++ .../services/test_chain_rollup_service.py | 122 ++++++++++++++++++ backend/workers/orchestrator.py | 39 +++++- 4 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/chain_rollup.py create mode 100644 backend/tests/unit/services/test_chain_rollup_service.py diff --git a/backend/app/db/repo/study.py b/backend/app/db/repo/study.py index d23a71e9..c29ef68c 100644 --- a/backend/app/db/repo/study.py +++ b/backend/app/db/repo/study.py @@ -338,7 +338,14 @@ async def get_chain_for_study( proposal_rows = ( await db.execute( select(Proposal.id, Proposal.study_id) - .where(Proposal.study_id.in_(link_ids), Proposal.status != "rejected") + # Phase 3 FR-4: filter widened from `!= "rejected"` to also + # exclude `superseded` so the chain panel's per-link proposal + # resolution honors the rollup. Cascades automatically to + # `list_recent_completed_chains` (which reuses this function). + .where( + Proposal.study_id.in_(link_ids), + Proposal.status.notin_(("rejected", "superseded")), + ) .order_by( Proposal.study_id, Proposal.created_at.desc(), diff --git a/backend/app/services/chain_rollup.py b/backend/app/services/chain_rollup.py new file mode 100644 index 00000000..e05e49b8 --- /dev/null +++ b/backend/app/services/chain_rollup.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 soundminds.ai +# +# SPDX-License-Identifier: Apache-2.0 + +"""Chain-rollup service — supersede non-winning chain links' proposals. + +feat_overnight_final_solution_phase3 Story 2.1 (FR-2). + +Walks the chain anchored at a given study, identifies the winner via +:func:`select_best_link` (Phase 1 infra), and delegates the loser +supersession to :func:`repo.bulk_mark_superseded`. Returns the +``(superseded_count, superseded_ids)`` tuple so the caller can emit the +``chain_proposals_superseded`` structlog event AFTER its commit succeeds +(spec D-19). Does NOT commit; caller commits per the service-layer +convention. + +The service is chain-scoped, not proposal-scoped — landing under +``services/`` rather than appending to ``agent_proposals_dispatch.py`` +keeps it usable by both the autopilot path (``_stop`` in +``backend/workers/orchestrator.py``) and any future chat-agent surface +(spec D-4 / Q5 locked). +""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.db import repo +from backend.app.domain.study.chain_summary import ( + derive_chain_stop_reason, + select_best_link, +) + + +async def mark_non_winning_chain_proposals_superseded( + db: AsyncSession, + *, + study_id: str, +) -> tuple[int, list[str]]: + """Supersede ``pending`` proposals of all chain links other than the winner. + + Walks the chain anchored at the study root of ``study_id`` and, when + the chain has terminated with at least 2 completed links and a clear + best link, conditional-UPDATEs all sibling losers' ``pending`` + proposals to ``superseded`` (via :func:`repo.bulk_mark_superseded`). + + Returns ``(count, ids)`` — the count and IDs actually transitioned — + so the caller can emit the post-commit ``chain_proposals_superseded`` + structlog event with the full IDs payload per spec D-19. + + Idempotent. Re-running on the same chain after a successful first + call returns ``(0, [])`` (the losers are now ``superseded`` and the + repo helper's ``WHERE status='pending'`` clause excludes them). + + Early-returns ``(0, [])`` when: + a. ``repo.get_chain_for_study`` returns ``None`` (study not found). + b. The chain has fewer than 2 links (single-link chain has no + siblings to supersede). + c. The derived ``stop_reason == "in_flight"`` (chain still + running; rollup deferred until termination). + d. :func:`select_best_link` returns ``None`` (no completed link → + no winner → nothing to supersede against). + + Does NOT commit. Caller commits as part of its own transaction + boundary (e.g., ``_stop`` commits the link's ``pending`` proposal + insert and the rollup in the same transaction). + """ + traversal = await repo.get_chain_for_study(db, study_id) + if traversal is None: + return (0, []) + if len(traversal.links) < 2: + return (0, []) + stop_reason = derive_chain_stop_reason(traversal.links, traversal.anchor_trials) + if stop_reason == "in_flight": + return (0, []) + best_link_id = select_best_link(traversal.links) + if best_link_id is None: + return (0, []) + loser_ids = [link.id for link in traversal.links if link.id != best_link_id] + transitioned = await repo.bulk_mark_superseded(db, study_ids=loser_ids) + return (len(transitioned), transitioned) diff --git a/backend/tests/unit/services/test_chain_rollup_service.py b/backend/tests/unit/services/test_chain_rollup_service.py new file mode 100644 index 00000000..d2f8b158 --- /dev/null +++ b/backend/tests/unit/services/test_chain_rollup_service.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: 2026 soundminds.ai +# +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the Phase 3 chain-rollup service helper. + +Mocks ``repo.get_chain_for_study`` + ``select_best_link`` + +``derive_chain_stop_reason`` + ``repo.bulk_mark_superseded`` so the test +runs without a DB. Covers the four early-return paths (chain missing, +single-link, in_flight, no winner) and the happy path. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from backend.app.services import chain_rollup + + +@dataclass(frozen=True) +class _StubLink: + id: str + best_metric: float | None = None + status: str = "completed" + + +def _stub_traversal(links: list[_StubLink]) -> SimpleNamespace: + return SimpleNamespace( + anchor_id=links[0].id if links else "", + links=links, + proposal_id_by_link_id={}, + anchor_trials=None, + ) + + +async def _call( + monkeypatch: pytest.MonkeyPatch, + *, + traversal: SimpleNamespace | None, + stop_reason: str = "no_lift", + best_link_id: str | None = "winner", + bulk_returns: list[str] | None = None, +) -> tuple[int, list[str]]: + from backend.app.db import repo as repo_mod + + monkeypatch.setattr( + repo_mod, + "get_chain_for_study", + AsyncMock(return_value=traversal), + ) + monkeypatch.setattr( + repo_mod, + "bulk_mark_superseded", + AsyncMock(return_value=bulk_returns or []), + ) + monkeypatch.setattr( + chain_rollup, + "derive_chain_stop_reason", + lambda links, anchor_trials: stop_reason, + ) + monkeypatch.setattr(chain_rollup, "select_best_link", lambda links: best_link_id) + db_stub: Any = object() + return await chain_rollup.mark_non_winning_chain_proposals_superseded(db_stub, study_id="any") + + +async def test_returns_zero_when_chain_missing(monkeypatch: pytest.MonkeyPatch) -> None: + result = await _call(monkeypatch, traversal=None) + assert result == (0, []) + + +async def test_returns_zero_for_single_link_chain(monkeypatch: pytest.MonkeyPatch) -> None: + result = await _call(monkeypatch, traversal=_stub_traversal([_StubLink("only")])) + assert result == (0, []) + + +async def test_returns_zero_when_chain_still_in_flight( + monkeypatch: pytest.MonkeyPatch, +) -> None: + result = await _call( + monkeypatch, + traversal=_stub_traversal([_StubLink("a"), _StubLink("b")]), + stop_reason="in_flight", + ) + assert result == (0, []) + + +async def test_returns_zero_when_no_best_link(monkeypatch: pytest.MonkeyPatch) -> None: + result = await _call( + monkeypatch, + traversal=_stub_traversal([_StubLink("a"), _StubLink("b")]), + best_link_id=None, + ) + assert result == (0, []) + + +async def test_happy_path_returns_count_and_ids(monkeypatch: pytest.MonkeyPatch) -> None: + """Two losers, repo returned both IDs; service surfaces ``(2, [...])``.""" + result = await _call( + monkeypatch, + traversal=_stub_traversal( + [_StubLink("loser_a"), _StubLink("winner"), _StubLink("loser_b")] + ), + best_link_id="winner", + bulk_returns=["loser_a_prop", "loser_b_prop"], + ) + assert result == (2, ["loser_a_prop", "loser_b_prop"]) + + +async def test_happy_path_with_zero_returned_rows(monkeypatch: pytest.MonkeyPatch) -> None: + """Race: repo found no ``pending`` rows to transition; service returns ``(0, [])``.""" + result = await _call( + monkeypatch, + traversal=_stub_traversal([_StubLink("a"), _StubLink("b")]), + best_link_id="a", + bulk_returns=[], + ) + assert result == (0, []) diff --git a/backend/workers/orchestrator.py b/backend/workers/orchestrator.py index 0fdff305..198781da 100644 --- a/backend/workers/orchestrator.py +++ b/backend/workers/orchestrator.py @@ -56,9 +56,10 @@ from backend.app.db.repo.trial import TrialsSummary, aggregate_trials_summary from backend.app.db.session import get_session_factory from backend.app.domain.study.baseline_resolver import resolve_baseline_params +from backend.app.domain.study.chain_summary import select_best_link from backend.app.domain.study.search_space import SearchSpace, apply_search_space from backend.app.eval.optuna_runtime import build_pruner, build_sampler, get_or_create_study -from backend.app.services import study_state +from backend.app.services import chain_rollup, study_state logger = structlog.get_logger(__name__) @@ -712,6 +713,12 @@ async def _stop( picked up by ``feat_digest_proposal``'s boot-time scan when that feature ships. """ + # Phase 3 FR-5/FR-7: chain-rollup payload captured inside the + # transaction; structlog event fires AFTER commit per spec D-19. + superseded_count = 0 + superseded_ids: list[str] = [] + chain_anchor_id: str | None = None + best_link_id: str | None = None try: await study_state.complete_study( db, @@ -738,6 +745,22 @@ async def _stop( metric_delta=None, status="pending", ) + # Phase 3 FR-5: chain rollup runs in the same transaction as + # the link's pending proposal insert. Cheap heuristic gate to + # skip standalone (non-chain) studies entirely: + config_depth = (study.config or {}).get("auto_followup_depth") + could_be_in_chain = study.parent_study_id is not None or config_depth not in (None, 0) + if could_be_in_chain: + # Capture anchor + winner for the post-commit log payload. + traversal = await repo.get_chain_for_study(db, study_id) + if traversal is not None and len(traversal.links) >= 2: + chain_anchor_id = traversal.anchor_id + best_link_id = select_best_link(traversal.links) + count, ids = await chain_rollup.mark_non_winning_chain_proposals_superseded( + db, study_id=study_id + ) + superseded_count = count + superseded_ids = ids await db.commit() except study_state.InvalidStateTransition: await db.rollback() @@ -749,6 +772,20 @@ async def _stop( ) return + # Phase 3 FR-7 / D-19: emit the chain-rollup structlog event AFTER + # commit succeeds — pre-commit emission would risk the transaction + # rolling back while the log claims durable supersession. + if superseded_count > 0: + logger.info( + "chain_proposals_superseded", + event_type="chain_proposals_superseded", + study_id=study_id, + chain_anchor_id=chain_anchor_id, + best_link_id=best_link_id, + superseded_count=superseded_count, + superseded_proposal_ids=superseded_ids, + ) + # Best-effort fast-path digest enqueue. try: await arq_pool.enqueue_job("generate_digest", study_id) From 7f29f1def2b7ebd8350c9201f76b17a9c1a342f7 Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Thu, 4 Jun 2026 22:55:59 -0400 Subject: [PATCH 3/7] feat(proposals): reinstate endpoint + ?include_superseded flag (Story 3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat_overnight_final_solution_phase3 Epic 3 (FR-6 + FR-7 operator half). - POST /api/v1/proposals/{id}/reinstate transitions superseded → pending via the read-check-mutate repo helper (spec D-17). Reuses existing PROPOSAL_NOT_FOUND (404) and INVALID_STATE_TRANSITION (409) codes per D-16. Emits chain_proposal_reinstated structlog AFTER db.commit() per D-19. - GET /api/v1/proposals gains ?include_superseded: bool = False (D-15 revised). When status= is unset AND include_superseded=False, the repo helpers append Proposal.status != 'superseded'. Explicit ?status= always beats implicit include_superseded (single-value backward compat preserved). 10 integration tests at backend/tests/integration/test_proposal_reinstate.py covering all 4 reinstate paths + 5 ?include_superseded behaviors + existing ?status=pending backward compat. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: SoundMindsAI --- backend/app/api/v1/proposals.py | 63 ++++++ backend/app/db/repo/proposal.py | 16 +- .../integration/test_proposal_reinstate.py | 185 ++++++++++++++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 backend/tests/integration/test_proposal_reinstate.py diff --git a/backend/app/api/v1/proposals.py b/backend/app/api/v1/proposals.py index c30194c6..f9bf0af0 100644 --- a/backend/app/api/v1/proposals.py +++ b/backend/app/api/v1/proposals.py @@ -376,6 +376,7 @@ async def list_proposals_endpoint( cursor: Annotated[str | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT, sort: Annotated[ProposalSortKey | None, Query()] = None, + include_superseded: Annotated[bool, Query()] = False, ) -> ProposalsListResponse: """List proposals with cursor pagination + filters. @@ -384,6 +385,11 @@ async def list_proposals_endpoint( study-detail page's pending-proposal lookup). Both reject invalid UUIDs with 422 via FastAPI's UUID parsing. ``?sort=`` (Story 1.3) is a :data:`ProposalSortKey` value with sort-aware cursor. + + Phase 3 D-15 revised: ``?include_superseded`` defaults to ``False``; + when ``False`` AND no ``?status=`` is set, the response omits + ``superseded`` rows. Explicit ``?status=`` always beats implicit + ``include_superseded`` (single-value backward compat preserved). """ parsed_sort = parse_sort(sort, _PROPOSAL_SORT_COLUMNS) decoded_cursor: tuple[object, str] | None = None @@ -408,6 +414,7 @@ async def list_proposals_endpoint( study_id=study_id_str, is_last_merged=is_last_merged, sort=sort, + include_superseded=include_superseded, ) ) has_more = len(rows) > limit @@ -421,6 +428,7 @@ async def list_proposals_endpoint( template_id=template_id_str, study_id=study_id_str, is_last_merged=is_last_merged, + include_superseded=include_superseded, ) response.headers["X-Total-Count"] = str(total) next_cursor: str | None = None @@ -531,4 +539,59 @@ async def open_pr_endpoint( ) +# --------------------------------------------------------------------------- +# POST /api/v1/proposals/{id}/reinstate (Phase 3, FR-6) +# --------------------------------------------------------------------------- + + +@router.post( + "/proposals/{proposal_id}/reinstate", + response_model=ProposalDetail, + tags=["proposals"], +) +async def reinstate_proposal_endpoint( + proposal_id: str, + db: Annotated[AsyncSession, Depends(get_db)], +) -> ProposalDetail: + """Phase 3 FR-6: ``superseded → pending`` transition. + + Mirrors :func:`reject_proposal_endpoint` (D-17 — read-check-mutate so + 404 vs 409 stays deterministic). Reuses ``INVALID_STATE_TRANSITION`` + per D-16; emits ``chain_proposal_reinstated`` structlog AFTER commit + per D-19. + """ + proposal = await repo.get_proposal(db, proposal_id) + if proposal is None: + raise _err(404, "PROPOSAL_NOT_FOUND", f"proposal {proposal_id} not found", False) + try: + await repo.reinstate_from_superseded(db, proposal_id=proposal_id) + except InvalidStateTransition as exc: + raise _err( + 409, + "INVALID_STATE_TRANSITION", + f"proposal {proposal_id} is in status {exc.current_status!r}; " + "only 'superseded' proposals can be reinstated", + False, + ) from exc + await db.commit() + # D-19: emit AFTER commit succeeds (pre-commit emission risks the + # transaction rolling back while the log claims a durable transition). + logger.info( + "chain_proposal_reinstated", + event_type="chain_proposal_reinstated", + proposal_id=proposal_id, + study_id=proposal.study_id, + prior_status="superseded", + ) + refreshed = await repo.get_proposal(db, proposal_id) + if refreshed is None: + raise _err( + 404, + "PROPOSAL_NOT_FOUND", + f"proposal {proposal_id} disappeared mid-update", + False, + ) + return await _assemble_proposal_detail(db, refreshed) + + __all__ = ["router"] diff --git a/backend/app/db/repo/proposal.py b/backend/app/db/repo/proposal.py index d2be34fb..14ff447d 100644 --- a/backend/app/db/repo/proposal.py +++ b/backend/app/db/repo/proposal.py @@ -173,6 +173,7 @@ async def list_proposals_paginated( study_id: str | None = None, is_last_merged: bool | None = None, sort: str | None = None, + include_superseded: bool = False, ) -> Sequence[Proposal]: """Cursor-paginated proposal list. Sort-aware (Story 1.3). @@ -185,11 +186,20 @@ async def list_proposals_paginated( (used by the study-detail page's pending-proposal lookup). ``is_last_merged`` (feat_config_repo_baseline_tracking FR-6) filters to proposals tracked (or not) as some config_repo's live pointer. + + Phase 3 D-15 revised: ``include_superseded`` defaults to ``False``; + when ``False`` AND ``status is None``, the implicit filter + ``Proposal.status != 'superseded'`` is applied so the default list + omits non-winning chain links. Explicit ``status`` overrides this + (e.g., ``status='superseded'`` returns only superseded rows). """ parsed_sort: ParsedSort | None = parse_sort(sort, _PROPOSAL_SORT_COLUMNS) stmt = select(Proposal) if status is not None: stmt = stmt.where(Proposal.status == status) + elif not include_superseded: + # Phase 3 D-15 revised: default response excludes superseded rows. + stmt = stmt.where(Proposal.status != "superseded") if cluster_id is not None: stmt = stmt.where(Proposal.cluster_id == cluster_id) if template_id is not None: @@ -224,17 +234,21 @@ async def count_proposals( template_id: str | None = None, study_id: str | None = None, is_last_merged: bool | None = None, + include_superseded: bool = False, ) -> int: """COUNT(*) for the ``X-Total-Count`` header on ``GET /api/v1/proposals``. ``template_id`` filter (Story 1.5) narrows by FK. ``study_id`` filter narrows to a single study. ``is_last_merged`` (feat_config_repo_baseline_tracking FR-6) restricts to the live-pointer - set or its complement. + set or its complement. ``include_superseded`` mirrors the rule on + :func:`list_proposals_paginated` — Phase 3 D-15 revised. """ stmt = select(func.count()).select_from(Proposal) if status is not None: stmt = stmt.where(Proposal.status == status) + elif not include_superseded: + stmt = stmt.where(Proposal.status != "superseded") if cluster_id is not None: stmt = stmt.where(Proposal.cluster_id == cluster_id) if template_id is not None: diff --git a/backend/tests/integration/test_proposal_reinstate.py b/backend/tests/integration/test_proposal_reinstate.py new file mode 100644 index 00000000..730a4c61 --- /dev/null +++ b/backend/tests/integration/test_proposal_reinstate.py @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: 2026 soundminds.ai +# +# SPDX-License-Identifier: Apache-2.0 + +"""POST /api/v1/proposals/{id}/reinstate tests (Phase 3 Story 3.1, FR-6). + +Mirrors the ``test_proposal_reject.py`` pattern (read-check-mutate, 404 ++ 409 discrimination). Covers AC-11, AC-12, AC-13 + ``?include_superseded`` +URL filter behavior (D-15 revised). +""" + +from __future__ import annotations + +import uuid + +import httpx +import pytest + +from backend.app.db import repo +from backend.app.db.session import get_session_factory +from backend.tests.conftest import postgres_reachable +from backend.tests.integration._digest_helpers import seed_completed_study + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not postgres_reachable(), + reason="Postgres not reachable — see docs/03_runbooks/local-dev.md", + ), +] + + +async def _supersede_proposal_directly(proposal_id: str) -> None: + """Flip a pending proposal to superseded via the repo helper. + + Used by the reinstate tests instead of seeding a full chain — keeps + these tests focused on the endpoint contract (chain-rollup coverage + lives in the orchestrator integration tests). + """ + factory = get_session_factory() + async with factory() as db: + proposal = await repo.get_proposal(db, proposal_id) + assert proposal is not None + await repo.bulk_mark_superseded(db, study_ids=[proposal.study_id]) # type: ignore[list-item] + await db.commit() + + +async def test_reinstate_superseded_returns_200_with_pending( + async_client: httpx.AsyncClient, +) -> None: + """AC-11: superseded → pending flip surfaces in the response body.""" + seeded = await seed_completed_study() + await _supersede_proposal_directly(seeded["proposal_id"]) + response = await async_client.post(f"/api/v1/proposals/{seeded['proposal_id']}/reinstate") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "pending" + assert body["id"] == seeded["proposal_id"] + + +async def test_reinstate_unknown_id_returns_404_proposal_not_found( + async_client: httpx.AsyncClient, +) -> None: + """AC-12: 404 PROPOSAL_NOT_FOUND on unknown id (D-17 discrimination).""" + response = await async_client.post(f"/api/v1/proposals/{uuid.uuid4()}/reinstate") + assert response.status_code == 404 + body = response.json() + assert body["detail"]["error_code"] == "PROPOSAL_NOT_FOUND" + assert body["detail"]["retryable"] is False + + +async def test_reinstate_pending_returns_409_invalid_state( + async_client: httpx.AsyncClient, +) -> None: + """AC-13: 409 INVALID_STATE_TRANSITION on already-pending row (D-16 reuse).""" + seeded = await seed_completed_study() + response = await async_client.post(f"/api/v1/proposals/{seeded['proposal_id']}/reinstate") + assert response.status_code == 409 + body = response.json() + assert body["detail"]["error_code"] == "INVALID_STATE_TRANSITION" + assert body["detail"]["retryable"] is False + assert "'pending'" in body["detail"]["message"] + + +async def test_reinstate_pr_opened_returns_409_invalid_state( + async_client: httpx.AsyncClient, +) -> None: + """Defense: pr_opened rows can't be reinstated either.""" + seeded = await seed_completed_study() + factory = get_session_factory() + async with factory() as db: + await repo.mark_proposal_pr_opened( + db, seeded["proposal_id"], pr_url="https://example.com/pr/1" + ) + await db.commit() + response = await async_client.post(f"/api/v1/proposals/{seeded['proposal_id']}/reinstate") + assert response.status_code == 409 + body = response.json() + assert body["detail"]["error_code"] == "INVALID_STATE_TRANSITION" + + +async def test_reinstate_idempotent_double_post_returns_409( + async_client: httpx.AsyncClient, +) -> None: + """A duplicate POST after a successful reinstate returns 409.""" + seeded = await seed_completed_study() + await _supersede_proposal_directly(seeded["proposal_id"]) + first = await async_client.post(f"/api/v1/proposals/{seeded['proposal_id']}/reinstate") + assert first.status_code == 200 + second = await async_client.post(f"/api/v1/proposals/{seeded['proposal_id']}/reinstate") + assert second.status_code == 409 + assert second.json()["detail"]["error_code"] == "INVALID_STATE_TRANSITION" + + +# --------------------------------------------------------------------------- +# ?include_superseded filter (D-15 revised) +# --------------------------------------------------------------------------- + + +async def test_list_default_omits_superseded( + async_client: httpx.AsyncClient, +) -> None: + """D-15 revised: default URL (no ?include_superseded) hides superseded rows.""" + seeded = await seed_completed_study() + await _supersede_proposal_directly(seeded["proposal_id"]) + response = await async_client.get("/api/v1/proposals") + assert response.status_code == 200 + ids = {row["id"] for row in response.json()["data"]} + assert seeded["proposal_id"] not in ids + + +async def test_list_include_superseded_true_includes_superseded( + async_client: httpx.AsyncClient, +) -> None: + """D-15 revised: ?include_superseded=true surfaces superseded rows.""" + seeded = await seed_completed_study() + await _supersede_proposal_directly(seeded["proposal_id"]) + response = await async_client.get("/api/v1/proposals?include_superseded=true") + assert response.status_code == 200 + ids = {row["id"] for row in response.json()["data"]} + assert seeded["proposal_id"] in ids + + +async def test_list_explicit_status_overrides_include_superseded( + async_client: httpx.AsyncClient, +) -> None: + """D-15 revised: explicit ?status= beats implicit include_superseded. + + ``?status=pending&include_superseded=true`` returns ONLY pending rows + (the superseded proposal is filtered by the explicit status, not + re-admitted by the boolean). + """ + seeded_a = await seed_completed_study() # stays pending + seeded_b = await seed_completed_study() + await _supersede_proposal_directly(seeded_b["proposal_id"]) + response = await async_client.get("/api/v1/proposals?status=pending&include_superseded=true") + assert response.status_code == 200 + ids = {row["id"] for row in response.json()["data"]} + assert seeded_a["proposal_id"] in ids + assert seeded_b["proposal_id"] not in ids + + +async def test_list_explicit_status_superseded_returns_only_superseded( + async_client: httpx.AsyncClient, +) -> None: + """FR-1 + D-15: ?status=superseded returns only the superseded rows.""" + seeded_pending = await seed_completed_study() + seeded_superseded = await seed_completed_study() + await _supersede_proposal_directly(seeded_superseded["proposal_id"]) + response = await async_client.get("/api/v1/proposals?status=superseded") + assert response.status_code == 200 + ids = {row["id"] for row in response.json()["data"]} + assert seeded_superseded["proposal_id"] in ids + assert seeded_pending["proposal_id"] not in ids + + +async def test_list_single_value_status_backward_compatible( + async_client: httpx.AsyncClient, +) -> None: + """D-15 revised: existing ?status=pending URLs unchanged (single-value contract).""" + seeded = await seed_completed_study() + response = await async_client.get("/api/v1/proposals?status=pending") + assert response.status_code == 200 + ids = {row["id"] for row in response.json()["data"]} + assert seeded["proposal_id"] in ids From 2ead188349ec760efa29a87ef9895fbe3040e77a Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Thu, 4 Jun 2026 23:10:36 -0400 Subject: [PATCH 4/7] feat(proposals): superseded badge + reinstate button + filter chip (Stories 4.1+5.1) feat_overnight_final_solution_phase3 Epic 4 + Epic 5. Story 4.1 (frontend): PROPOSAL_STATUS_VALUES gains 'superseded'; StatusBadge proposal: variant map adds superseded: 'outline' (D-12); new + ; useReinstateProposal mutation hook; useProposals threads ?include_superseded; 3 new glossary keys (proposal.status.superseded, proposal.reinstate, proposal.show_superseded_filter); enums-discipline test (5-tuple value-lock); regenerated ui/openapi.json + types.ts. Story 5.1 (docs): data-model.md proposals CHECK admits 'superseded' with transition note. proposal-state-management.md runbook + tutorial-first-study.md update deferred to follow-up. 1178 vitest tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: SoundMindsAI --- docs/01_architecture/data-model.md | 2 +- ui/openapi.json | 2 +- .../enums-proposal-status-discipline.test.ts | 38 +++++++++++ ui/src/__tests__/lib/enums.test.ts | 2 +- ui/src/app/proposals/[id]/page.tsx | 6 ++ ui/src/app/proposals/page.tsx | 25 +++++-- ui/src/components/common/status-badge.tsx | 4 ++ .../components/proposals/proposal-header.tsx | 1 + .../proposals/reinstate-proposal-button.tsx | 61 +++++++++++++++++ .../proposals/show-superseded-filter-chip.tsx | 43 ++++++++++++ ui/src/lib/api/proposals.ts | 44 +++++++++++- ui/src/lib/enums.ts | 8 ++- ui/src/lib/glossary.ts | 11 +++ ui/src/lib/types.ts | 68 ++++++++++++++++++- 14 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 ui/src/__tests__/lib/enums-proposal-status-discipline.test.ts create mode 100644 ui/src/components/proposals/reinstate-proposal-button.tsx create mode 100644 ui/src/components/proposals/show-superseded-filter-chip.tsx diff --git a/docs/01_architecture/data-model.md b/docs/01_architecture/data-model.md index 6c44d5b2..f34bd2a3 100644 --- a/docs/01_architecture/data-model.md +++ b/docs/01_architecture/data-model.md @@ -308,7 +308,7 @@ CREATE TABLE proposals ( template_id UUID NOT NULL REFERENCES query_templates(id), config_diff JSONB NOT NULL, -- {param: {from, to}} metric_delta JSONB, -- {ndcg@10: {baseline, achieved, delta_pct}}; null for hand-crafted - status TEXT NOT NULL CHECK (status IN ('pending', 'pr_opened', 'pr_merged', 'rejected')), + status TEXT NOT NULL CHECK (status IN ('pending', 'pr_opened', 'pr_merged', 'rejected', 'superseded')), -- 'superseded' added by feat_overnight_final_solution_phase3 (migration 0023). Transitions: pending → superseded (system, on chain termination via bulk_mark_superseded); superseded → pending (operator, via POST /api/v1/proposals/{id}/reinstate). pr_url TEXT, pr_state TEXT CHECK (pr_state IS NULL OR pr_state IN ('open', 'closed', 'merged')), -- mirrors GitHub pr_merged_at TIMESTAMPTZ, diff --git a/ui/openapi.json b/ui/openapi.json index 335cb0c9..04ce8535 100644 --- a/ui/openapi.json +++ b/ui/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"BulkQueriesResponse":{"description":"``POST /api/v1/query-sets/{id}/queries`` response.","properties":{"added":{"title":"Added","type":"integer"}},"required":["added"],"title":"BulkQueriesResponse","type":"object"},"CIShape":{"description":"Bootstrap percentile CI on the winner's per-query metric values.","properties":{"high":{"title":"High","type":"number"},"low":{"title":"Low","type":"number"},"method":{"const":"bootstrap_n1000","title":"Method","type":"string"},"n_samples":{"title":"N Samples","type":"integer"}},"required":["low","high","method","n_samples"],"title":"CIShape","type":"object"},"CalibrationResponse":{"description":"Calibration endpoint response.\n\nMirrors :class:`backend.app.eval.calibration.CalibrationResult` —\npersisted as ``judgment_lists.calibration`` JSONB.","properties":{"cohens_kappa":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cohens Kappa"},"n_samples":{"title":"N Samples","type":"integer"},"per_class":{"additionalProperties":{"type":"number"},"title":"Per Class","type":"object"},"warning":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Warning"},"weighted_kappa":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Weighted Kappa"}},"required":["cohens_kappa","weighted_kappa","per_class","n_samples","warning"],"title":"CalibrationResponse","type":"object"},"CalibrationSample":{"description":"One row in :class:`CalibrationSamplesRequest`.","properties":{"doc_id":{"maxLength":512,"minLength":1,"title":"Doc Id","type":"string"},"query_id":{"maxLength":36,"minLength":1,"title":"Query Id","type":"string"},"rating":{"enum":[0,1,2,3],"title":"Rating","type":"integer"}},"required":["query_id","doc_id","rating"],"title":"CalibrationSample","type":"object"},"CalibrationSamplesRequest":{"description":"Body for ``POST /api/v1/judgment-lists/{id}/calibration`` (Story 3.5).","properties":{"human_samples":{"items":{"$ref":"#/components/schemas/CalibrationSample"},"minItems":1,"title":"Human Samples","type":"array"}},"required":["human_samples"],"title":"CalibrationSamplesRequest","type":"object"},"CategoricalParam":{"additionalProperties":false,"description":"Discrete choice parameter.\n\nOptuna ``suggest_categorical`` handles strings, ints, floats, and bools\nas choices.","properties":{"choices":{"items":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"}]},"minItems":1,"title":"Choices","type":"array"},"type":{"const":"categorical","title":"Type","type":"string"}},"required":["type","choices"],"title":"CategoricalParam","type":"object"},"ClusterAggregateHealth":{"description":"Aggregate counts for the ``elasticsearch_clusters`` /healthz field (Story 3.5).\n\nPer spec §2: probes only the *registered* user clusters (from the DB),\nNOT the local Compose ES/OpenSearch — those have their own subsystem\nfields. ``status`` is a count derived from the cached ``cluster:health:*``\nentries; missing-cache or red/unreachable clusters are counted as\n``unreachable``.","properties":{"healthy":{"title":"Healthy","type":"integer"},"registered":{"title":"Registered","type":"integer"},"unreachable":{"title":"Unreachable","type":"integer"}},"required":["registered","healthy","unreachable"],"title":"ClusterAggregateHealth","type":"object"},"ClusterDetail":{"description":"``GET /api/v1/clusters/{id}`` response.","properties":{"auth_kind":{"enum":["es_apikey","es_basic","opensearch_basic","opensearch_sigv4","solr_basic","solr_apikey"],"title":"Auth Kind","type":"string"},"base_url":{"title":"Base Url","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"engine_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Engine Config"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"environment":{"enum":["prod","staging","dev"],"title":"Environment","type":"string"},"health_check":{"$ref":"#/components/schemas/HealthCheckResult"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes"},"target_filter":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Filter"}},"required":["id","name","engine_type","environment","base_url","auth_kind","created_at","health_check"],"title":"ClusterDetail","type":"object"},"ClusterListResponse":{"description":"Paginated list response.","properties":{"data":{"items":{"$ref":"#/components/schemas/ClusterSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"ClusterListResponse","type":"object"},"ClusterSummary":{"description":"List-view; drops engine_config + notes for brevity.","properties":{"auth_kind":{"enum":["es_apikey","es_basic","opensearch_basic","opensearch_sigv4","solr_basic","solr_apikey"],"title":"Auth Kind","type":"string"},"base_url":{"title":"Base Url","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"environment":{"enum":["prod","staging","dev"],"title":"Environment","type":"string"},"health_check":{"$ref":"#/components/schemas/HealthCheckResult"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"target_filter":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Filter"}},"required":["id","name","engine_type","environment","base_url","auth_kind","created_at","health_check"],"title":"ClusterSummary","type":"object"},"ConfidenceShape":{"description":"The top-level shape exposed via ``StudyDetail.confidence``.\n\nEvery sub-field is independently nullable per FR-7 — degraded paths\nsuppress only the sub-fields they affect, never the whole shape (the\norchestrator returns whole-object ``None`` only when the winner trial\nrow itself is missing).","properties":{"ci_95":{"anyOf":[{"$ref":"#/components/schemas/CIShape"},{"type":"null"}]},"convergence":{"anyOf":[{"$ref":"#/components/schemas/ConvergenceShape"},{"type":"null"}]},"headline":{"$ref":"#/components/schemas/HeadlineShape"},"late_trial_stddev":{"anyOf":[{"$ref":"#/components/schemas/LateTrialStddevShape"},{"type":"null"}]},"per_query_outcomes":{"anyOf":[{"$ref":"#/components/schemas/PerQueryOutcomesShape"},{"type":"null"}]},"runner_up_gap":{"anyOf":[{"$ref":"#/components/schemas/RunnerUpGapShape"},{"type":"null"}]}},"required":["headline","ci_95","runner_up_gap","late_trial_stddev","convergence","per_query_outcomes"],"title":"ConfidenceShape","type":"object"},"ConfigRepoDetail":{"description":"``GET /api/v1/config-repos/{id}`` response + ``POST`` 201 body.","properties":{"auth_ref":{"title":"Auth Ref","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"default_branch":{"title":"Default Branch","type":"string"},"id":{"title":"Id","type":"string"},"last_merged_proposal":{"anyOf":[{"$ref":"#/components/schemas/ProposalSummary"},{"type":"null"}]},"name":{"title":"Name","type":"string"},"pr_base_branch":{"title":"Pr Base Branch","type":"string"},"provider":{"const":"github","title":"Provider","type":"string"},"repo_url":{"title":"Repo Url","type":"string"},"webhook_registration_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Registration Error"},"webhook_secret_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Secret Ref"}},"required":["id","name","provider","repo_url","default_branch","pr_base_branch","auth_ref","webhook_secret_ref","webhook_registration_error","created_at"],"title":"ConfigRepoDetail","type":"object"},"ConfigReposListResponse":{"description":"``GET /api/v1/config-repos`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/ConfigRepoDetail"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"ConfigReposListResponse","type":"object"},"ConnectionTestRequest":{"description":"Body for ``POST /api/v1/clusters/test-connection`` (infra_adapter_solr Story A9).\n\nSame shape as ``CreateClusterRequest`` minus the persisted-only fields\n(``name``, ``environment``, ``notes``, ``target_filter``). ``engine_type``\n+ ``auth_kind`` are typed as ``str`` (not Literal) so a bad value yields\nthe project-standard 400 envelope rather than a raw 422 — same convention\nas ``CreateClusterRequest``.","properties":{"auth_kind":{"maxLength":64,"minLength":1,"title":"Auth Kind","type":"string"},"base_url":{"maxLength":512,"minLength":1,"title":"Base Url","type":"string"},"credentials_ref":{"maxLength":128,"minLength":1,"title":"Credentials Ref","type":"string"},"engine_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Engine Config"},"engine_type":{"maxLength":64,"minLength":1,"title":"Engine Type","type":"string"}},"required":["engine_type","base_url","auth_kind","credentials_ref"],"title":"ConnectionTestRequest","type":"object"},"ConnectionTestResult":{"description":"Response for ``POST /api/v1/clusters/test-connection``.\n\nAlways 200 — reachable vs unreachable surfaces via ``reachable`` +\n``status`` fields. The endpoint is a diagnostic, never a mutation,\nso it never returns 503; invalid engine×auth pairings 400 BEFORE the\nnetwork call. (Cycle-delta F1.)","properties":{"engine_capabilities":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Engine Capabilities"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"reachable":{"title":"Reachable","type":"boolean"},"status":{"enum":["green","yellow","red","unreachable"],"title":"Status","type":"string"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"}},"required":["reachable","status"],"title":"ConnectionTestResult","type":"object"},"ConvergenceShape":{"description":"Where the winner sits in the Optuna trial sequence + the classified regime.","properties":{"best_at_trial":{"title":"Best At Trial","type":"integer"},"regime":{"enum":["early_held","late_rising","noisy"],"title":"Regime","type":"string"},"total_trials":{"title":"Total Trials","type":"integer"}},"required":["best_at_trial","total_trials","regime"],"title":"ConvergenceShape","type":"object"},"ConversationDetail":{"description":"``GET /api/v1/conversations/{id}`` response.","properties":{"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"messages":{"items":{"$ref":"#/components/schemas/MessageWire"},"title":"Messages","type":"array"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"}},"required":["id","title","created_at","messages"],"title":"ConversationDetail","type":"object"},"ConversationSummary":{"description":"``GET /api/v1/conversations`` row + ``POST`` 201 body.\n\n``last_message_preview`` is the most recent user / assistant message's\n``content.text``, truncated at the repo layer to 120 chars (with ``…``\nsuffix when cut). Tool-role rows and assistant rows whose ``content.kind``\nis ``system_notice`` are skipped. ``None`` for brand-new conversations\nwith no qualifying messages — see ``chore_chat_last_message_preview``.\n\n``last_message_at`` is the ``created_at`` of that same row, or ``None``\nfor empty conversations. The list page uses it to render \"when did\nanyone last touch this thread\" instead of the conversation's\n``created_at``.","properties":{"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"last_message_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Last Message At"},"last_message_preview":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Message Preview"},"message_count":{"title":"Message Count","type":"integer"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"}},"required":["id","title","created_at","message_count"],"title":"ConversationSummary","type":"object"},"ConversationsListResponse":{"description":"``GET /api/v1/conversations`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/ConversationSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"ConversationsListResponse","type":"object"},"CreateClusterRequest":{"description":"Request body for ``POST /api/v1/clusters``.\n\nSee module docstring for the deliberate ``str`` vs ``Literal`` split.","properties":{"auth_kind":{"maxLength":64,"minLength":1,"title":"Auth Kind","type":"string"},"base_url":{"maxLength":512,"minLength":1,"title":"Base Url","type":"string"},"credentials_ref":{"maxLength":128,"minLength":1,"title":"Credentials Ref","type":"string"},"engine_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Engine Config"},"engine_type":{"maxLength":64,"minLength":1,"title":"Engine Type","type":"string"},"environment":{"enum":["prod","staging","dev"],"title":"Environment","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[a-z0-9][a-z0-9-]*$","title":"Name","type":"string"},"notes":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Notes"},"target_filter":{"anyOf":[{"maxLength":256,"minLength":1,"type":"string"},{"type":"null"}],"description":"Optional glob pattern (fnmatch.fnmatchcase: *, ?, [seq], [!seq]; no brace expansion). Scopes GET /clusters/{id}/targets to matching index names. Null = no filter.","title":"Target Filter"}},"required":["name","engine_type","environment","base_url","auth_kind","credentials_ref"],"title":"CreateClusterRequest","type":"object"},"CreateConfigRepoRequest":{"description":"Body of ``POST /api/v1/config-repos`` (FR-3).\n\n``provider`` is server-derived from ``repo_url`` (cycle-2 F4 from\nspec review) — NOT in the payload. The validator enforces a strict\nGitHub URL pattern; non-GitHub URLs surface as 400\n``UNSUPPORTED_PROVIDER`` at the router layer.","properties":{"auth_ref":{"maxLength":128,"minLength":1,"pattern":"^[a-zA-Z0-9_-]+$","title":"Auth Ref","type":"string"},"default_branch":{"default":"main","maxLength":128,"minLength":1,"title":"Default Branch","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[a-z0-9][a-z0-9-]*$","title":"Name","type":"string"},"pr_base_branch":{"default":"main","maxLength":128,"minLength":1,"title":"Pr Base Branch","type":"string"},"repo_url":{"maxLength":512,"minLength":1,"title":"Repo Url","type":"string"},"webhook_secret_ref":{"anyOf":[{"maxLength":128,"pattern":"^[a-zA-Z0-9_-]+$","type":"string"},{"type":"null"}],"title":"Webhook Secret Ref"}},"required":["name","repo_url","auth_ref"],"title":"CreateConfigRepoRequest","type":"object"},"CreateConversationRequest":{"description":"``POST /api/v1/conversations`` body.","properties":{"title":{"anyOf":[{"maxLength":200,"type":"string"},{"type":"null"}],"title":"Title"}},"title":"CreateConversationRequest","type":"object"},"CreateJudgmentListFromUbiRequest":{"description":"Body for ``POST /api/v1/judgments/generate-from-ubi`` (Story 3.2 / FR-3).\n\nMirrors :class:`backend.app.services.agent_judgments_dispatch.UbiJudgmentGenerationRequest`.\nThe ``@model_validator(mode=\"after\")`` enforces the conditional\nrequiredness of ``current_template_id`` + ``rubric`` per the hybrid\nconverter: REQUIRED when ``converter == 'hybrid_ubi_llm'`` (the LLM-\nfill path needs both); FORBIDDEN otherwise (pure UBI never calls\nthe LLM so accepting them silently would mask operator error).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"converter":{"enum":["ctr_threshold","dwell_time","hybrid_ubi_llm"],"title":"Converter","type":"string"},"converter_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Converter Config"},"current_template_id":{"anyOf":[{"maxLength":36,"minLength":36,"type":"string"},{"type":"null"}],"title":"Current Template Id"},"description":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Description"},"llm_fill_threshold":{"anyOf":[{"minimum":1.0,"type":"integer"},{"type":"null"}],"default":20,"title":"Llm Fill Threshold"},"mapping_strategy":{"default":"reject","enum":["reject","first_match","most_recent"],"title":"Mapping Strategy","type":"string"},"min_impressions_threshold":{"anyOf":[{"minimum":1.0,"type":"integer"},{"type":"null"}],"default":100,"title":"Min Impressions Threshold"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"query_set_id":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"},"rubric":{"anyOf":[{"minLength":1,"type":"string"},{"type":"null"}],"title":"Rubric"},"since":{"format":"date-time","title":"Since","type":"string"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"},"until":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Until"}},"required":["name","query_set_id","cluster_id","target","since","converter"],"title":"CreateJudgmentListFromUbiRequest","type":"object"},"CreateJudgmentListGenerateRequest":{"description":"Body for ``POST /api/v1/judgments/generate`` (Story 3.1).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"current_template_id":{"maxLength":36,"minLength":1,"title":"Current Template Id","type":"string"},"description":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Description"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"query_set_id":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"},"rubric":{"minLength":1,"title":"Rubric","type":"string"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"}},"required":["name","query_set_id","cluster_id","target","current_template_id","rubric"],"title":"CreateJudgmentListGenerateRequest","type":"object"},"CreateProposalRequest":{"description":"Body of ``POST /api/v1/proposals`` (manual proposal creation, FR-4 / AC-6).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"config_diff":{"additionalProperties":true,"title":"Config Diff","type":"object"},"metric_delta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metric Delta"},"template_id":{"maxLength":36,"minLength":1,"title":"Template Id","type":"string"}},"required":["cluster_id","template_id","config_diff"],"title":"CreateProposalRequest","type":"object"},"CreateQuerySetRequest":{"description":"``POST /api/v1/query-sets`` body.\n\n``cluster_id`` is required because Phase 1's shipped schema has\n``query_sets.cluster_id NOT NULL``. Spec FR-3 wording (``cluster_id?``)\nis documented drift tracked at\n``docs/00_overview/planned_features/chore_spec_query_set_cluster_id_drift/idea.md``.","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"description":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Description"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"}},"required":["name","cluster_id"],"title":"CreateQuerySetRequest","type":"object"},"CreateQueryTemplateRequest":{"description":"Request body for ``POST /api/v1/query-templates``.","properties":{"body":{"minLength":1,"title":"Body","type":"string"},"declared_params":{"additionalProperties":{"type":"string"},"title":"Declared Params","type":"object"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"}},"required":["name","engine_type","body"],"title":"CreateQueryTemplateRequest","type":"object"},"CreateStudyRequest":{"description":"``POST /api/v1/studies`` body.\n\n``search_space`` is validated post-Pydantic-parse via\n:class:`backend.app.domain.study.search_space.SearchSpace` so\n:exc:`pydantic.ValidationError` produces the spec's 400\n``INVALID_SEARCH_SPACE`` (per Story 3.3 task 2).\n\nfeat_digest_executable_followups Story 4.2 — optional ``parent`` field\nrecords the parent proposal + followup-index lineage when the study\nwas spawned from a digest \"Run this followup\" action (FR-11).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"config":{"$ref":"#/components/schemas/StudyConfigSpec"},"judgment_list_id":{"maxLength":36,"minLength":1,"title":"Judgment List Id","type":"string"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"objective":{"$ref":"#/components/schemas/ObjectiveSpec"},"parent":{"anyOf":[{"$ref":"#/components/schemas/ParentFollowupRef"},{"type":"null"}]},"parent_study_id":{"anyOf":[{"maxLength":36,"minLength":36,"type":"string"},{"type":"null"}],"description":"feat_study_clone_from_previous FR-7 — when the operator clones an existing study via the study-detail Clone button, this carries the source study's id. Server validates existence (404 PARENT_STUDY_NOT_FOUND) and same-cluster (422 PARENT_STUDY_WRONG_CLUSTER) before persisting to studies.parent_study_id. Independent of the proposal-lineage 'parent' field (D-5); both may be set.","title":"Parent Study Id"},"query_set_id":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"},"search_space":{"additionalProperties":true,"title":"Search Space","type":"object"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"},"template_id":{"maxLength":36,"minLength":1,"title":"Template Id","type":"string"}},"required":["name","cluster_id","target","template_id","query_set_id","judgment_list_id","search_space","objective","config"],"title":"CreateStudyRequest","type":"object"},"CurvePoint":{"description":"One point on the best-so-far curve.\n\n``trial_number`` is the trial's ``optuna_trial_number`` (the canonical\n\"trial order within the study\" field — see ``auto_followup.py`` module\ndocstring for why we sort by this rather than ``started_at``).\n``best_so_far`` is the running extremum of ``primary_metric`` over all\nearlier trials, sign-corrected to the study's optimization direction.","properties":{"best_so_far":{"title":"Best So Far","type":"number"},"trial_number":{"title":"Trial Number","type":"integer"}},"required":["trial_number","best_so_far"],"title":"CurvePoint","type":"object"},"DigestResponse":{"description":"Body of ``GET /api/v1/studies/{id}/digest`` (FR-3 / AC-3).\n\nfeat_digest_executable_followups Story 4.1 — ``suggested_followups`` is\nnow a discriminated-union list (NarrowFollowup | WidenFollowup |\nTextFollowup), populated by the digest handler via\n``parse_followup_list(digest.suggested_followups, ...)`` so legacy or\nmalformed JSONB payloads never crash the response.","properties":{"generated_at":{"format":"date-time","title":"Generated At","type":"string"},"generated_by":{"title":"Generated By","type":"string"},"id":{"title":"Id","type":"string"},"narrative":{"title":"Narrative","type":"string"},"parameter_importance":{"additionalProperties":{"type":"number"},"title":"Parameter Importance","type":"object"},"recommended_config":{"additionalProperties":true,"title":"Recommended Config","type":"object"},"study_id":{"title":"Study Id","type":"string"},"suggested_followups":{"items":{"$ref":"#/components/schemas/FollowupItem"},"title":"Suggested Followups","type":"array"}},"required":["id","study_id","narrative","parameter_importance","recommended_config","suggested_followups","generated_by","generated_at"],"title":"DigestResponse","type":"object"},"Document":{"description":"A single document by ID — return shape of ``SearchAdapter.get_document``.\n\nMirrors :class:`ScoredHit` minus ``score`` (browsing doesn't need scoring).\n``source`` is ``None`` when the engine's index has ``_source: false`` mapping.","properties":{"doc_id":{"minLength":1,"title":"Doc Id","type":"string"},"source":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Source"}},"required":["doc_id"],"title":"Document","type":"object"},"DocumentListResponse":{"description":"``GET /api/v1/clusters/{cluster_id}/targets/{target}/documents`` response.\n\n``next_cursor`` opaque-encodes the ES ``hits[-1].sort`` array of the\nlast visible row when ``has_more`` is True (see\n``backend.app.api.v1._documents_cursor``). The ``X-Total-Count`` header\non the response carries the engine's ``hits.total.value``.","properties":{"data":{"items":{"$ref":"#/components/schemas/DocumentSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"DocumentListResponse","type":"object"},"DocumentSummary":{"description":"One row in the documents list (per FR-3 / FR-8).\n\n``source`` is the *truncated* preview emitted by\n``backend.app.services.documents.truncate_source_for_list``. The detail\nendpoint returns the untruncated ``Document.source``.","properties":{"doc_id":{"minLength":1,"title":"Doc Id","type":"string"},"source":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Source"}},"required":["doc_id","source"],"title":"DocumentSummary","type":"object"},"FieldSpec":{"description":"One field returned by ``get_schema``.","properties":{"analyzer":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Analyzer"},"doc_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Doc Count"},"name":{"title":"Name","type":"string"},"type":{"title":"Type","type":"string"}},"required":["name","type"],"title":"FieldSpec","type":"object"},"FloatParam":{"additionalProperties":false,"description":"Continuous float parameter.\n\n``log=True`` enables log-uniform sampling\n(Optuna's ``suggest_float(..., log=True)``); requires ``low > 0``.","properties":{"high":{"title":"High","type":"number"},"log":{"default":false,"title":"Log","type":"boolean"},"low":{"title":"Low","type":"number"},"type":{"const":"float","title":"Type","type":"string"}},"required":["type","low","high"],"title":"FloatParam","type":"object"},"FollowupItem":{"discriminator":{"mapping":{"narrow":"#/components/schemas/NarrowFollowup","swap_template":"#/components/schemas/SwapTemplateFollowup","text":"#/components/schemas/TextFollowup","widen":"#/components/schemas/WidenFollowup"},"propertyName":"kind"},"oneOf":[{"$ref":"#/components/schemas/NarrowFollowup"},{"$ref":"#/components/schemas/WidenFollowup"},{"$ref":"#/components/schemas/TextFollowup"},{"$ref":"#/components/schemas/SwapTemplateFollowup"}]},"GenerateJudgmentsResponse":{"description":"Response of ``POST /api/v1/judgments/generate``.\n\nPer GPT-5.5 cycle 1 F5 — the endpoint registers a typed\n``response_model`` so OpenAPI introspection + contract tests can verify\nthe wire shape.","properties":{"judgment_list_id":{"title":"Judgment List Id","type":"string"},"status":{"const":"generating","title":"Status","type":"string"}},"required":["judgment_list_id","status"],"title":"GenerateJudgmentsResponse","type":"object"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"HeadlineShape":{"description":"Top-line metric value + N(queries) used in the CI.\n\n``metric`` uses ``str`` (not ``ObjectiveMetric``) to avoid a circular\nimport: ``schemas.py`` imports ``ConfidenceShape`` from here, so this\nmodule cannot import back from ``schemas.py``. The upstream value is\nalready validated by the existing ``ObjectiveMetric`` Literal at the\ncreate-study endpoint (``schemas.py:214``).","properties":{"k":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"K"},"metric":{"title":"Metric","type":"string"},"n_queries":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"N Queries"},"value":{"title":"Value","type":"number"}},"required":["metric","value","k","n_queries"],"title":"HeadlineShape","type":"object"},"HealthCheckResult":{"description":"Wire shape of the per-cluster health probe (mirrors ``HealthStatus``).","properties":{"checked_at":{"title":"Checked At","type":"string"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"status":{"enum":["green","yellow","red","unreachable"],"title":"Status","type":"string"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"}},"required":["status","checked_at"],"title":"HealthCheckResult","type":"object"},"HealthResponse":{"description":"The /healthz response body. Same shape for HTTP 200 and 503.","properties":{"openai_capabilities":{"$ref":"#/components/schemas/OpenAICapabilities"},"openai_endpoint":{"description":"Configured OPENAI_BASE_URL","title":"Openai Endpoint","type":"string"},"status":{"enum":["ok","degraded"],"title":"Status","type":"string"},"subsystems":{"$ref":"#/components/schemas/Subsystems"},"uptime_seconds":{"description":"Seconds since the API process started","title":"Uptime Seconds","type":"integer"},"version":{"description":"Application version (relyloop_git_sha)","title":"Version","type":"string"}},"required":["status","subsystems","openai_endpoint","openai_capabilities","version","uptime_seconds"],"title":"HealthResponse","type":"object"},"ImportJudgmentItem":{"description":"One row in :class:`ImportJudgmentListRequest`.","properties":{"doc_id":{"maxLength":512,"minLength":1,"title":"Doc Id","type":"string"},"notes":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Notes"},"query_id":{"maxLength":36,"minLength":1,"title":"Query Id","type":"string"},"rating":{"enum":[0,1,2,3],"title":"Rating","type":"integer"}},"required":["query_id","doc_id","rating"],"title":"ImportJudgmentItem","type":"object"},"ImportJudgmentListRequest":{"description":"Body for ``POST /api/v1/judgment-lists/import`` (Story 3.2).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"description":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Description"},"judgments":{"items":{"$ref":"#/components/schemas/ImportJudgmentItem"},"maxItems":100000,"minItems":1,"title":"Judgments","type":"array"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"query_set_id":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"},"rubric":{"minLength":1,"title":"Rubric","type":"string"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"}},"required":["name","query_set_id","cluster_id","target","rubric","judgments"],"title":"ImportJudgmentListRequest","type":"object"},"IntParam":{"additionalProperties":false,"description":"Integer parameter inclusive of both bounds.","properties":{"high":{"title":"High","type":"integer"},"low":{"title":"Low","type":"integer"},"type":{"const":"int","title":"Type","type":"string"}},"required":["type","low","high"],"title":"IntParam","type":"object"},"JudgmentListDetail":{"description":"``GET /api/v1/judgment-lists/{id}`` response.\n\nNote: ``generation_params`` is populated for UBI lists (feat_ubi_judgments\nStory 1.1's JSONB column) and NULL for LLM lists. The Story 4.3 UI\n(```` + ````) reads the\npayload to discriminate UBI/hybrid lists and to reconstruct the\noriginal request for the ambiguous-skip \"Re-run with most_recent\"\naffordance.","properties":{"calibration":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Calibration"},"cluster_id":{"title":"Cluster Id","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"current_template_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Current Template Id"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"failed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failed Reason"},"generation_params":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Generation Params"},"id":{"title":"Id","type":"string"},"judgment_count":{"title":"Judgment Count","type":"integer"},"name":{"title":"Name","type":"string"},"query_set_id":{"title":"Query Set Id","type":"string"},"rubric":{"title":"Rubric","type":"string"},"source_breakdown":{"$ref":"#/components/schemas/_SourceBreakdown"},"status":{"enum":["generating","complete","failed"],"title":"Status","type":"string"},"target":{"title":"Target","type":"string"}},"required":["id","name","description","query_set_id","cluster_id","target","current_template_id","rubric","status","failed_reason","judgment_count","source_breakdown","calibration","generation_params","created_at"],"title":"JudgmentListDetail","type":"object"},"JudgmentListJudgmentsResponse":{"description":"``GET /api/v1/judgment-lists/{id}/judgments`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/JudgmentRow"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"JudgmentListJudgmentsResponse","type":"object"},"JudgmentListListResponse":{"description":"``GET /api/v1/judgment-lists`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/JudgmentListSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"JudgmentListListResponse","type":"object"},"JudgmentListRef":{"description":"One entry in the ``QUERY_HAS_JUDGMENTS`` 409 envelope.\n\nLives in ``detail.judgment_lists``. Maps from the repo-layer\n:class:`backend.app.db.repo.judgment.JudgmentListRefRow` at the\nrouter boundary.","properties":{"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"}},"required":["id","name"],"title":"JudgmentListRef","type":"object"},"JudgmentListSummary":{"description":"List-view row on ``GET /api/v1/judgment-lists``.","properties":{"cluster_id":{"title":"Cluster Id","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"query_set_id":{"title":"Query Set Id","type":"string"},"status":{"enum":["generating","complete","failed"],"title":"Status","type":"string"},"target":{"title":"Target","type":"string"}},"required":["id","name","description","query_set_id","cluster_id","target","status","created_at"],"title":"JudgmentListSummary","type":"object"},"JudgmentRow":{"description":"``GET /api/v1/judgment-lists/{id}/judgments`` row + PATCH response.","properties":{"confidence":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Confidence"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"doc_id":{"title":"Doc Id","type":"string"},"id":{"title":"Id","type":"string"},"judgment_list_id":{"title":"Judgment List Id","type":"string"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes"},"query_id":{"title":"Query Id","type":"string"},"rater_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Rater Ref"},"rating":{"enum":[0,1,2,3],"title":"Rating","type":"integer"},"source":{"enum":["llm","human","click"],"title":"Source","type":"string"}},"required":["id","judgment_list_id","query_id","doc_id","rating","source","rater_ref","confidence","notes","created_at"],"title":"JudgmentRow","type":"object"},"LateTrialStddevShape":{"description":"Sample stddev of ``primary_metric`` over the late-trial window.","properties":{"min_window_required":{"title":"Min Window Required","type":"integer"},"value":{"title":"Value","type":"number"},"window_size":{"title":"Window Size","type":"integer"}},"required":["value","window_size","min_window_required"],"title":"LateTrialStddevShape","type":"object"},"MessageWire":{"description":"One row of ``GET /api/v1/conversations/{id}.messages``.","properties":{"content":{"additionalProperties":true,"title":"Content","type":"object"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"role":{"enum":["user","assistant","tool"],"title":"Role","type":"string"},"tool_calls":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Tool Calls"}},"required":["id","role","content","created_at"],"title":"MessageWire","type":"object"},"NarrowFollowup":{"additionalProperties":false,"description":"A 'narrow' followup — re-run with a tighter range than the parent.","properties":{"kind":{"const":"narrow","title":"Kind","type":"string"},"rationale":{"title":"Rationale","type":"string"},"search_space":{"$ref":"#/components/schemas/SearchSpace"}},"required":["kind","rationale","search_space"],"title":"NarrowFollowup","type":"object"},"ObjectiveSpec":{"description":"Wire shape of ``studies.objective`` (write-side validated at create).\n\n``k`` is required for ``ndcg`` / ``precision`` / ``recall`` (per\nstandard IR-evaluation conventions: those metrics are computed at a\ncutoff rank). ``map`` accepts ``k`` optionally; ``mrr`` / ``err`` ignore\nit. The model_validator enforces this so a malformed objective\nsurfaces as 400 ``INVALID_SEARCH_SPACE`` / 422 ``VALIDATION_ERROR``\nat study-create time rather than failing later inside ``run_trial``\nwhen the worker computes the metric.","properties":{"direction":{"default":"maximize","enum":["maximize","minimize"],"title":"Direction","type":"string"},"k":{"anyOf":[{"enum":[1,3,5,10,20,50,100],"type":"integer"},{"type":"null"}],"title":"K"},"metric":{"enum":["ndcg","map","precision","recall","mrr"],"title":"Metric","type":"string"}},"required":["metric"],"title":"ObjectiveSpec","type":"object"},"OpenAICapabilities":{"description":"Cached results of the OpenAI capability check (Story 3.3 populates Redis).\n\nStep 1 (``models_endpoint``) is reported first because it gates the rest:\nwhen it fails, the other three are reported as ``\"untested\"``. The\n``models_endpoint_status_code`` field is required-but-nullable\n(per ``bug_openai_capability_check_incapable_on_valid_key`` spec §19 D-3/D-8)\n— always present in the JSON, ``null`` when not applicable. This lets\noperators distinguish ``401 -> bad key``, ``429 -> quota``,\n``5xx -> upstream outage``, ``null -> network unreachable / cache miss``.","properties":{"chat":{"description":"Chat completion probe result","enum":["ok","fail","untested"],"title":"Chat","type":"string"},"function_calling":{"description":"Function-calling probe result (tool_choice=required)","enum":["ok","fail","untested"],"title":"Function Calling","type":"string"},"models_endpoint":{"description":"GET /models probe outcome. 'ok' / 'fail' are projected from CapabilityResult.models_endpoint; 'untested' is the cache-miss default, matching the existing chat / function_calling / structured_output cache-miss handling.","enum":["ok","fail","untested"],"title":"Models Endpoint","type":"string"},"models_endpoint_status_code":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"HTTP status code from the GET /models probe when it HTTP-failed (>= 400). null for the success path, network-class failure (timeout / DNS / connection-refused), or cache miss. Required-but-nullable: the JSON key is always present with explicit null when no value, never omitted.","title":"Models Endpoint Status Code"},"structured_output":{"description":"JSON-schema response_format probe result","enum":["ok","fail","untested"],"title":"Structured Output","type":"string"}},"required":["models_endpoint","models_endpoint_status_code","chat","function_calling","structured_output"],"title":"OpenAICapabilities","type":"object"},"OpenPrResponse":{"description":"Body of ``POST /api/v1/proposals/{id}/open_pr`` (FR-1).\n\nReturned with HTTP 202 on successful enqueue. Status is always\n``'pending'`` at enqueue time; the worker flips it to ``'pr_opened'``\nafter the PR is open.","properties":{"message":{"title":"Message","type":"string"},"proposal_id":{"title":"Proposal Id","type":"string"},"status":{"const":"pending","title":"Status","type":"string"}},"required":["proposal_id","status","message"],"title":"OpenPrResponse","type":"object"},"OverrideJudgmentRequest":{"description":"Body for ``PATCH /api/v1/judgment-lists/{id}/judgments/{judgment_id}``.\n\n``rating`` is INTENTIONALLY unbounded at the Pydantic layer — spec §8.5\nrequires out-of-range failures to surface as 400 ``INVALID_RATING`` (not\nPydantic's default 422 ``VALIDATION_ERROR``). The handler validates the\nvalue manually and raises the domain code (per GPT-5.5 cycle 1 F4).","properties":{"notes":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Notes"},"rating":{"title":"Rating","type":"integer"}},"required":["rating"],"title":"OverrideJudgmentRequest","type":"object"},"ParentFollowupRef":{"description":"Optional lineage payload on ``POST /api/v1/studies``.\n\nfeat_digest_executable_followups FR-11 — when the operator clicks\n\"Run this followup\" on a proposal's digest card, the create-study\npayload carries the parent proposal's id + the 0-based index into\nthe digest's ``suggested_followups`` array so the spawned study\nremembers where it came from.\n\n``proposal_id`` is a UUIDv7 (36-char hex). The exact-length bound\nforces malformed strings to surface as 422 ``VALIDATION_ERROR``\nrather than reach the DB FK check and emerge as a 404\n``PROPOSAL_NOT_FOUND``.","properties":{"followup_index":{"minimum":0.0,"title":"Followup Index","type":"integer"},"proposal_id":{"maxLength":36,"minLength":36,"title":"Proposal Id","type":"string"}},"required":["proposal_id","followup_index"],"title":"ParentFollowupRef","type":"object"},"PerQueryOutcomesShape":{"description":"Per-query outcome counts + the top-5 named regressors and improvers.","properties":{"comparison_against":{"enum":["runner_up","baseline"],"title":"Comparison Against","type":"string"},"improved":{"title":"Improved","type":"integer"},"regressed":{"title":"Regressed","type":"integer"},"top_improvers":{"default":[],"items":{"$ref":"#/components/schemas/RegressorRowShape"},"title":"Top Improvers","type":"array"},"top_regressors":{"items":{"$ref":"#/components/schemas/RegressorRowShape"},"title":"Top Regressors","type":"array"},"unchanged":{"title":"Unchanged","type":"integer"}},"required":["improved","unchanged","regressed","comparison_against","top_regressors"],"title":"PerQueryOutcomesShape","type":"object"},"ProposalDetail":{"description":"Body of the proposal detail endpoints.\n\nUsed by ``GET /api/v1/proposals/{id}``, ``POST /api/v1/proposals``,\nand ``POST /api/v1/proposals/{id}/reject``.","properties":{"cluster":{"$ref":"#/components/schemas/_ClusterEmbed"},"config_diff":{"additionalProperties":true,"title":"Config Diff","type":"object"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"digest":{"anyOf":[{"$ref":"#/components/schemas/_DigestEmbed"},{"type":"null"}]},"id":{"title":"Id","type":"string"},"is_currently_live":{"default":false,"title":"Is Currently Live","type":"boolean"},"metric_delta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metric Delta"},"pr_merged_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Pr Merged At"},"pr_open_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pr Open Error"},"pr_state":{"anyOf":[{"enum":["open","closed","merged"],"type":"string"},{"type":"null"}],"title":"Pr State"},"pr_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pr Url"},"rejected_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Rejected Reason"},"status":{"enum":["pending","pr_opened","pr_merged","rejected"],"title":"Status","type":"string"},"study_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Study Id"},"study_summary":{"anyOf":[{"$ref":"#/components/schemas/_StudySummary"},{"type":"null"}]},"study_trial_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Study Trial Id"},"template":{"$ref":"#/components/schemas/_TemplateEmbed"}},"required":["id","study_id","study_summary","study_trial_id","cluster","template","config_diff","metric_delta","status","pr_url","pr_state","pr_merged_at","pr_open_error","rejected_reason","digest","created_at"],"title":"ProposalDetail","type":"object"},"ProposalSummary":{"description":"Row in the ``GET /api/v1/proposals`` list response.","properties":{"cluster":{"$ref":"#/components/schemas/_ClusterEmbed"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"is_currently_live":{"default":false,"title":"Is Currently Live","type":"boolean"},"metric_delta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metric Delta"},"pr_state":{"anyOf":[{"enum":["open","closed","merged"],"type":"string"},{"type":"null"}],"title":"Pr State"},"pr_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pr Url"},"status":{"enum":["pending","pr_opened","pr_merged","rejected"],"title":"Status","type":"string"},"study_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Study Id"},"template":{"$ref":"#/components/schemas/_TemplateEmbed"}},"required":["id","study_id","cluster","template","status","pr_state","pr_url","metric_delta","created_at"],"title":"ProposalSummary","type":"object"},"ProposalsListResponse":{"description":"Body of ``GET /api/v1/proposals``.","properties":{"data":{"items":{"$ref":"#/components/schemas/ProposalSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"ProposalsListResponse","type":"object"},"QueryHasJudgmentsDetail":{"description":"The ``detail`` object of a 409 ``QUERY_HAS_JUDGMENTS`` response.\n\nExtends the canonical ``{error_code, message, retryable}`` envelope\nwith two structured fields the frontend consumes directly\n(``judgment_lists`` + ``overflow_count``). Wired into the FastAPI\nroute's ``responses={409: {\"model\": QueryHasJudgmentsEnvelope}}`` so\nthe OpenAPI schema documents the contract.","properties":{"error_code":{"const":"QUERY_HAS_JUDGMENTS","title":"Error Code","type":"string"},"judgment_lists":{"items":{"$ref":"#/components/schemas/JudgmentListRef"},"title":"Judgment Lists","type":"array"},"message":{"title":"Message","type":"string"},"overflow_count":{"title":"Overflow Count","type":"integer"},"retryable":{"const":false,"title":"Retryable","type":"boolean"}},"required":["error_code","message","retryable","judgment_lists","overflow_count"],"title":"QueryHasJudgmentsDetail","type":"object"},"QueryHasJudgmentsEnvelope":{"description":"Top-level 409 wrapper (FastAPI nests under ``detail`` for HTTPException).","properties":{"detail":{"$ref":"#/components/schemas/QueryHasJudgmentsDetail"}},"required":["detail"],"title":"QueryHasJudgmentsEnvelope","type":"object"},"QueryListResponse":{"description":"``GET /api/v1/query-sets/{set_id}/queries`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/QueryRow"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"QueryListResponse","type":"object"},"QueryRow":{"description":"Wire row returned by the per-query GET + PATCH endpoints.\n\nUsed by both ``GET /api/v1/query-sets/{set_id}/queries`` and\n``PATCH /api/v1/query-sets/{set_id}/queries/{query_id}``.\n``judgment_count`` is a derived field — single batched GROUP BY in the\nrouter via :func:`backend.app.db.repo.judgment.count_judgments_per_query`.","properties":{"id":{"title":"Id","type":"string"},"judgment_count":{"title":"Judgment Count","type":"integer"},"query_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Query Metadata"},"query_text":{"title":"Query Text","type":"string"},"reference_answer":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reference Answer"}},"required":["id","query_text","reference_answer","query_metadata","judgment_count"],"title":"QueryRow","type":"object"},"QuerySetDetail":{"description":"``GET /api/v1/query-sets/{id}`` response.","properties":{"cluster_id":{"title":"Cluster Id","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"query_count":{"title":"Query Count","type":"integer"}},"required":["id","name","description","cluster_id","query_count","created_at"],"title":"QuerySetDetail","type":"object"},"QuerySetListResponse":{"description":"``GET /api/v1/query-sets`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/QuerySetSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"QuerySetListResponse","type":"object"},"QuerySetSummary":{"description":"List-view shape.\n\n``query_count`` is the number of queries in the set. It is resolved\nvia a single batched ``GROUP BY query_set_id`` aggregate per page\n(``repo.count_queries_for_sets``), NOT a per-row count — so the\nlist endpoint stays at a fixed 2 queries (the page + the count\naggregate) regardless of page size. This is the same no-N+1 pattern\n``feat_studies_convergence_visibility`` (PR #421) used for the\nstudies-list ``trial_count`` field.","properties":{"cluster_id":{"title":"Cluster Id","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"query_count":{"title":"Query Count","type":"integer"}},"required":["id","name","cluster_id","query_count","created_at"],"title":"QuerySetSummary","type":"object"},"QueryTemplateDetail":{"description":"``GET /api/v1/query-templates/{id}`` response.","properties":{"body":{"title":"Body","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"declared_params":{"additionalProperties":{"type":"string"},"title":"Declared Params","type":"object"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"version":{"title":"Version","type":"integer"}},"required":["id","name","engine_type","body","declared_params","version","parent_id","created_at"],"title":"QueryTemplateDetail","type":"object"},"QueryTemplateListResponse":{"description":"``GET /api/v1/query-templates`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/QueryTemplateSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"QueryTemplateListResponse","type":"object"},"QueryTemplateSummary":{"description":"List-view shape; drops ``body`` + the full ``declared_params`` dict.\n\nSurfaces ``param_count`` (= ``len(declared_params)``) so the\ntemplates list can show each template's tuning surface at a glance.\n``param_count`` is free to compute — ``declared_params`` is a JSONB\ncolumn already loaded on the row (not a child relationship), so the\ncount is ``len(row.declared_params)`` with no extra query and no\nN+1 risk. The full dict remains on ``QueryTemplateDetail``.","properties":{"created_at":{"format":"date-time","title":"Created At","type":"string"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"param_count":{"title":"Param Count","type":"integer"},"version":{"title":"Version","type":"integer"}},"required":["id","name","engine_type","version","param_count","created_at"],"title":"QueryTemplateSummary","type":"object"},"RecentChainSummary":{"description":"One row in the ``GET /api/v1/studies/chains/recent`` response.\n\nPer spec §8.1 (feat_overnight_studies_summary_card). Per-chain\nrollup feeding the \"Ran while you were away\" card on ``/studies``\n— anchor identity + chain length + the best link's metric + the\nchain's cumulative lift + the derived stop reason + the\nsurfaceable proposal id for the best link. Read-only; no state\ntransitions, no audit events.","properties":{"anchor_name":{"title":"Anchor Name","type":"string"},"anchor_study_id":{"title":"Anchor Study Id","type":"string"},"best_link_proposal_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Best Link Proposal Id"},"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"chain_length":{"title":"Chain Length","type":"integer"},"cumulative_lift":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cumulative Lift"},"direction":{"enum":["maximize","minimize"],"title":"Direction","type":"string"},"objective_metric":{"title":"Objective Metric","type":"string"},"stop_reason":{"enum":["depth_exhausted","no_lift","budget","parent_failed","cancelled","in_flight"],"title":"Stop Reason","type":"string"},"tail_completed_at":{"format":"date-time","title":"Tail Completed At","type":"string"}},"required":["anchor_study_id","anchor_name","chain_length","best_metric","objective_metric","cumulative_lift","direction","stop_reason","best_link_proposal_id","tail_completed_at"],"title":"RecentChainSummary","type":"object"},"RecentChainsResponse":{"description":"``GET /api/v1/studies/chains/recent`` response shape.\n\nInert pagination: this endpoint emits ``next_cursor=null`` and\n``has_more=false`` always (OQ-2 resolved — limit-cap only). The\nfields stay on the wire for consistency with the rest of the\nstudies surface, so a future MVP3 keyset-pagination story can\npopulate them without breaking clients (idea filed in this PR).","properties":{"data":{"items":{"$ref":"#/components/schemas/RecentChainSummary"},"title":"Data","type":"array"},"has_more":{"default":false,"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data"],"title":"RecentChainsResponse","type":"object"},"RegressorRowShape":{"description":"One row in the named-regressors or named-improvers table.\n\nUsed for BOTH the ``top_regressors`` and ``top_improvers`` lists.\nThe wire shape is identical — ``delta = winner_score - comparison_score``\nis negative on the regressor list, positive on the improver list. The\nclass name is historical (regressors shipped first); reusing the same\ntype keeps the schema and the per-row renderer compact.","properties":{"comparison_score":{"title":"Comparison Score","type":"number"},"delta":{"title":"Delta","type":"number"},"query_id":{"title":"Query Id","type":"string"},"query_text":{"title":"Query Text","type":"string"},"winner_score":{"title":"Winner Score","type":"number"}},"required":["query_id","query_text","winner_score","comparison_score","delta"],"title":"RegressorRowShape","type":"object"},"RejectProposalRequest":{"description":"Body of ``POST /api/v1/proposals/{id}/reject`` (FR-4 / AC-5).","properties":{"reason":{"anyOf":[{"maxLength":500,"type":"string"},{"type":"null"}],"title":"Reason"}},"title":"RejectProposalRequest","type":"object"},"ReseedStatusResponse":{"additionalProperties":false,"description":"Polling-endpoint response for ``GET /api/v1/_test/demo/reseed/status``.\n\nPer ``bug_demo_reseed_fake_metric_regression`` D-2. Lives in Redis as a\nsingle JSON blob keyed by :data:`DEMO_RESEED_STATUS_KEY` so the\nhandler reads it in one round-trip.","properties":{"current_step":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Current Step"},"failed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failed Reason"},"finished_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Finished At"},"scenarios_completed":{"default":0,"title":"Scenarios Completed","type":"integer"},"scenarios_skipped":{"items":{"type":"string"},"title":"Scenarios Skipped","type":"array"},"scenarios_total":{"default":0,"title":"Scenarios Total","type":"integer"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"status":{"enum":["idle","running","complete","failed"],"title":"Status","type":"string"},"steps":{"items":{"type":"string"},"title":"Steps","type":"array"},"summary":{"anyOf":[{"$ref":"#/components/schemas/ReseedSummary"},{"type":"null"}]}},"required":["status"],"title":"ReseedStatusResponse","type":"object"},"ReseedSummary":{"additionalProperties":false,"description":"Returned by :func:`reseed_demo_state` on success.\n\nPer spec §9 Required invariants, every counter is exactly 4 on the\nhappy path; ``duration_ms`` is wall-clock from orchestration start\nto the rename commit.","properties":{"clusters_created":{"title":"Clusters Created","type":"integer"},"duration_ms":{"title":"Duration Ms","type":"integer"},"proposals_created":{"title":"Proposals Created","type":"integer"},"query_sets_created":{"title":"Query Sets Created","type":"integer"},"studies_completed":{"title":"Studies Completed","type":"integer"}},"required":["clusters_created","query_sets_created","studies_completed","proposals_created","duration_ms"],"title":"ReseedSummary","type":"object"},"RunQueryHit":{"description":"One hit in the ``run_query`` response.","properties":{"doc_id":{"title":"Doc Id","type":"string"},"score":{"title":"Score","type":"number"},"source":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Source"}},"required":["doc_id","score"],"title":"RunQueryHit","type":"object"},"RunQueryRequest":{"description":"``POST /api/v1/clusters/{id}/run_query`` body.","properties":{"query_dsl":{"additionalProperties":true,"title":"Query Dsl","type":"object"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"},"top_k":{"default":10,"maximum":1000.0,"minimum":1.0,"title":"Top K","type":"integer"}},"required":["target","query_dsl"],"title":"RunQueryRequest","type":"object"},"RunQueryResponse":{"description":"``POST /api/v1/clusters/{id}/run_query`` response.","properties":{"hits":{"items":{"$ref":"#/components/schemas/RunQueryHit"},"title":"Hits","type":"array"}},"required":["hits"],"title":"RunQueryResponse","type":"object"},"RunnerUpGapShape":{"description":"Runner-up trial's metric vs the winner.\n\nThe whole shape is suppressed to ``None`` when there are <2 complete\ntrials (FR-2 + FR-7); ``classification`` is non-null whenever this shape\nis present.","properties":{"classification":{"enum":["robust_plateau","sharp_peak"],"title":"Classification","type":"string"},"runner_up_metric":{"title":"Runner Up Metric","type":"number"},"top10_within":{"title":"Top10 Within","type":"number"},"value":{"title":"Value","type":"number"}},"required":["value","classification","top10_within","runner_up_metric"],"title":"RunnerUpGapShape","type":"object"},"Schema":{"description":"An index / collection's field schema.","properties":{"fields":{"items":{"$ref":"#/components/schemas/FieldSpec"},"title":"Fields","type":"array"},"name":{"title":"Name","type":"string"}},"required":["name","fields"],"title":"Schema","type":"object"},"SearchSpace":{"additionalProperties":false,"description":"Pydantic model for the ``studies.search_space`` JSONB column.\n\nWire format::\n\n {\n \"params\": {\n \"boost_title\": {\"type\": \"float\", \"low\": 0.1, \"high\": 10.0, \"log\": true},\n \"min_should_match\": {\"type\": \"int\", \"low\": 1, \"high\": 5},\n \"operator\": {\"type\": \"categorical\", \"choices\": [\"and\", \"or\"]},\n }\n }","properties":{"params":{"additionalProperties":{"discriminator":{"mapping":{"categorical":"#/components/schemas/CategoricalParam","float":"#/components/schemas/FloatParam","int":"#/components/schemas/IntParam"},"propertyName":"type"},"oneOf":[{"$ref":"#/components/schemas/FloatParam"},{"$ref":"#/components/schemas/IntParam"},{"$ref":"#/components/schemas/CategoricalParam"}]},"minProperties":1,"title":"Params","type":"object"}},"required":["params"],"title":"SearchSpace","type":"object"},"SeedAutoFollowupChainRequest":{"additionalProperties":false,"description":"Payload for ``POST /api/v1/_test/auto-followup/seed-chain``.\n\nSeeds ``depth + 1`` linked studies (root → … → leaf) so E2E tests can\ncover the chain-panel parent-link / children-table / cascade-radio paths\nthat the public ``POST /api/v1/studies`` endpoint can't drive\n(``parent_study_id`` is set only by the auto-followup worker).\n\nCloses ``chore_auto_followup_e2e_chain_seed_helper`` (idea #2).","properties":{"cluster_id":{"minLength":1,"title":"Cluster Id","type":"string"},"depth":{"description":"Number of chain hops to seed. depth=1 → root + leaf (2 nodes). depth=2 → root + 1 middle + leaf (3 nodes).","maximum":5.0,"minimum":1.0,"title":"Depth","type":"integer"},"in_flight_leaf":{"default":true,"description":"When True (default), the deepest node is left at status='queued'. When False, it's driven to 'completed' too. Default True matches the primary E2E use case: cascade-radio coverage where the middle node needs an in-flight child.","title":"In Flight Leaf","type":"boolean"},"in_flight_middle":{"default":true,"description":"When True (default), the immediate parent of the leaf is left at status='queued' so the Cancel button is enabled (canCancel = running || queued per study-action-bar.tsx:46). Required for the cancel-modal cascade-radio test. When False, all intermediates are completed (more realistic chain state but cancel modal won't open on the middle).","title":"In Flight Middle","type":"boolean"},"judgment_list_id":{"minLength":1,"title":"Judgment List Id","type":"string"},"query_set_id":{"minLength":1,"title":"Query Set Id","type":"string"},"template_id":{"minLength":1,"title":"Template Id","type":"string"}},"required":["cluster_id","query_set_id","template_id","judgment_list_id","depth"],"title":"SeedAutoFollowupChainRequest","type":"object"},"SeedAutoFollowupChainResponse":{"description":"IDs of every node in the seeded chain, in parent→child order.","properties":{"leaf_id":{"title":"Leaf Id","type":"string"},"middle_ids":{"items":{"type":"string"},"title":"Middle Ids","type":"array"},"root_id":{"title":"Root Id","type":"string"}},"required":["root_id","middle_ids","leaf_id"],"title":"SeedAutoFollowupChainResponse","type":"object"},"SeedCompletedStudyRequest":{"additionalProperties":false,"description":"Payload for ``POST /api/v1/_test/studies/seed-completed``.\n\nAll four FK fields are required; the caller is responsible for\nseeding the parent rows first (typically via the public\n``seedFullChain`` E2E helper).","properties":{"cluster_id":{"minLength":1,"title":"Cluster Id","type":"string"},"extra_trial_metrics":{"anyOf":[{"items":{"type":"number"},"type":"array"},{"type":"null"}],"description":"Optional list of additional complete-trial `primary_metric` values (numbered from 2 upward) seeded on top of the default winner (0.487) + runner-up (0.412). Used to push the study past the convergence classifier's usable-trial floor (5) so the `` renders a real verdict + curve instead of the too_few_trials null state (feat_study_convergence_indicator). Every value MUST be < 0.487 so the winner / best_metric / proposal / digest stay anchored to the unchanged 0.412 -> 0.487 story. Omit for the default 2-trial shape.","title":"Extra Trial Metrics"},"judgment_list_id":{"minLength":1,"title":"Judgment List Id","type":"string"},"query_set_id":{"minLength":1,"title":"Query Set Id","type":"string"},"runner_up_per_query":{"anyOf":[{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object"},{"type":"null"}],"description":"Optional per-query metrics for the runner-up trial; pairs with `winner_per_query`.","title":"Runner Up Per Query"},"suggested_followups":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"description":"feat_digest_executable_followups Story 6.1 — optional structured FollowupItem list (`[{kind, rationale, search_space}]`) to seed on the digest. When omitted, the seeder writes two default text-kind items. The E2E Run-followup spec passes a `narrow` item so it can drive the per-card Run button + modal prefill flow.","title":"Suggested Followups"},"template_id":{"minLength":1,"title":"Template Id","type":"string"},"winner_per_query":{"anyOf":[{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object"},{"type":"null"}],"description":"Optional per-query metrics dict to populate on the winner trial. Shape: `{query_id: {metric_token: float}}` where metric_token matches what `scoring.score()` emits (e.g. `ndcg@10`). Set alongside `runner_up_per_query` to drive the ConfidencePanel happy path on `/studies/[id]`. When omitted, the seeded trials have `per_query_metrics IS NULL` (the pre-feat_pr_metric_confidence shape).","title":"Winner Per Query"},"with_pending_proposal":{"default":true,"description":"When true (default), also insert a `status='pending'` proposal linked to the study so the digest panel's Open PR button renders enabled. Set false to test the AC-11 aria-disabled-button + tooltip path.","title":"With Pending Proposal","type":"boolean"}},"required":["cluster_id","query_set_id","template_id","judgment_list_id"],"title":"SeedCompletedStudyRequest","type":"object"},"SeedCompletedStudyResponse":{"description":"IDs of the inserted rows; mirrors :class:`SeededStudyTriple`.","properties":{"digest_id":{"title":"Digest Id","type":"string"},"proposal_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Proposal Id"},"study_id":{"title":"Study Id","type":"string"}},"required":["study_id","digest_id","proposal_id"],"title":"SeedCompletedStudyResponse","type":"object"},"SendMessageRequest":{"description":"``POST /api/v1/conversations/{id}/messages`` body (Story 3.2).","properties":{"content":{"$ref":"#/components/schemas/SendMessageRequestContent"},"role":{"const":"user","default":"user","title":"Role","type":"string"}},"required":["content"],"title":"SendMessageRequest","type":"object"},"SendMessageRequestContent":{"description":"Sub-shape inside :class:`SendMessageRequest`.","properties":{"text":{"maxLength":20000,"minLength":1,"title":"Text","type":"string"}},"required":["text"],"title":"SendMessageRequestContent","type":"object"},"StudyChainLink":{"description":"One link in the rolled-up overnight-chain summary (feat_overnight_autopilot §8.3).","properties":{"auto_followup_depth_remaining":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Auto Followup Depth Remaining"},"baseline_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Baseline Metric"},"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"completed_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Completed At"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"delta_from_prev":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Delta From Prev"},"direction":{"enum":["maximize","minimize"],"title":"Direction","type":"string"},"failed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failed Reason"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"proposal_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Proposal Id"},"selected_followup_kind":{"anyOf":[{"enum":["narrow_default","narrow","widen","swap_template"],"type":"string"},{"type":"null"}],"title":"Selected Followup Kind"},"status":{"enum":["queued","running","completed","cancelled","failed"],"title":"Status","type":"string"},"template_id":{"title":"Template Id","type":"string"}},"required":["id","name","status","best_metric","baseline_metric","direction","delta_from_prev","proposal_id","auto_followup_depth_remaining","failed_reason","created_at","completed_at","template_id"],"title":"StudyChainLink","type":"object"},"StudyChainResponse":{"description":"``GET /api/v1/studies/{id}/chain`` response (feat_overnight_autopilot §8.3).","properties":{"anchor_study_id":{"title":"Anchor Study Id","type":"string"},"best_link_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Best Link Id"},"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"cumulative_lift":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cumulative Lift"},"direction":{"enum":["maximize","minimize"],"title":"Direction","type":"string"},"links":{"items":{"$ref":"#/components/schemas/StudyChainLink"},"title":"Links","type":"array"},"proposal_id_for_best_link":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Proposal Id For Best Link"},"stop_reason":{"enum":["depth_exhausted","no_lift","budget","parent_failed","cancelled","in_flight"],"title":"Stop Reason","type":"string"}},"required":["anchor_study_id","best_link_id","best_metric","cumulative_lift","direction","stop_reason","proposal_id_for_best_link","links"],"title":"StudyChainResponse","type":"object"},"StudyConfigSpec":{"description":"Wire shape of ``studies.config`` (write-side).\n\nThe model_validator below enforces that at least one stop condition is\nset — otherwise the study has no terminating condition (FR-4).\n``parallelism`` / ``trial_timeout_s`` are optional; when absent the\nworker reads ``Settings.studies_default_parallelism`` /\n``studies_default_timeout_s`` at job time. The API layer does NOT\nmaterialize these fields into the stored row — see Story 1.5 +\nStory 3.3's ``config.model_dump(exclude_none=True, exclude_unset=True)``\ncontract.","properties":{"auto_followup_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Auto Followup Depth"},"auto_followup_strategy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Auto Followup Strategy"},"baseline_params":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Baseline Params"},"max_trials":{"anyOf":[{"maximum":100000.0,"minimum":1.0,"type":"integer"},{"type":"null"}],"title":"Max Trials"},"parallelism":{"anyOf":[{"maximum":64.0,"minimum":1.0,"type":"integer"},{"type":"null"}],"title":"Parallelism"},"pruner":{"anyOf":[{"enum":["median","none"],"type":"string"},{"type":"null"}],"title":"Pruner"},"sampler":{"anyOf":[{"enum":["tpe","random"],"type":"string"},{"type":"null"}],"title":"Sampler"},"secondary_metrics":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Secondary Metrics"},"seed":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Seed"},"time_budget_min":{"anyOf":[{"exclusiveMinimum":0.0,"type":"number"},{"type":"null"}],"title":"Time Budget Min"},"trial_timeout_s":{"anyOf":[{"maximum":3600.0,"minimum":5.0,"type":"integer"},{"type":"null"}],"title":"Trial Timeout S"}},"title":"StudyConfigSpec","type":"object"},"StudyConvergenceShape":{"description":"Verdict + supporting numerics for the UI panel and the digest narrative.\n\nMirrors the ``ConfidenceShape`` pattern from ``confidence.py``: the\ndomain module owns the Pydantic model, and ``backend.app.api.v1.schemas``\nre-exports it for the ``StudyDetail.convergence`` field. The\n``best_so_far_curve`` is the chart's data series; ``verdict`` is the\nbadge label.\n\n**Name discipline (plan §0).** The bare class name ``ConvergenceShape``\nis already taken by :class:`backend.app.domain.study.confidence.ConvergenceShape`\n(a different concept — winner-trial *timing*, not metric plateau).\n``StudyConvergenceShape`` is the study-level analogue; the confidence\nsub-shape stays on its inner module. The two coexist on ``StudyDetail``\n(``confidence.convergence`` is the inner one; ``convergence`` is this\none), and FastAPI emits both under their bare class names in the\nOpenAPI schema — no fully-qualified disambiguation noise leaks to the\nfrontend.","properties":{"best_so_far_curve":{"items":{"$ref":"#/components/schemas/CurvePoint"},"title":"Best So Far Curve","type":"array"},"direction":{"enum":["maximize","minimize"],"title":"Direction","type":"string"},"epsilon":{"title":"Epsilon","type":"number"},"improvement_in_window":{"title":"Improvement In Window","type":"number"},"total_complete_trials":{"title":"Total Complete Trials","type":"integer"},"verdict":{"enum":["converged","still_improving","too_few_trials"],"title":"Verdict","type":"string"},"warmup_floor":{"title":"Warmup Floor","type":"integer"},"window_size":{"title":"Window Size","type":"integer"}},"required":["verdict","direction","window_size","epsilon","warmup_floor","total_complete_trials","improvement_in_window","best_so_far_curve"],"title":"StudyConvergenceShape","type":"object"},"StudyDetail":{"description":"``GET /api/v1/studies/{id}`` response + ``POST/cancel`` response.","properties":{"baseline_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Baseline Metric"},"baseline_trial_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Baseline Trial Id"},"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"best_trial_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Best Trial Id"},"cluster_id":{"title":"Cluster Id","type":"string"},"completed_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Completed At"},"confidence":{"anyOf":[{"$ref":"#/components/schemas/ConfidenceShape"},{"type":"null"}]},"config":{"additionalProperties":true,"title":"Config","type":"object"},"convergence":{"anyOf":[{"$ref":"#/components/schemas/StudyConvergenceShape"},{"type":"null"}]},"created_at":{"format":"date-time","title":"Created At","type":"string"},"failed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failed Reason"},"id":{"title":"Id","type":"string"},"judgment_list_id":{"title":"Judgment List Id","type":"string"},"name":{"title":"Name","type":"string"},"objective":{"additionalProperties":true,"title":"Objective","type":"object"},"optuna_study_name":{"title":"Optuna Study Name","type":"string"},"parent_study_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Study Id"},"query_set_id":{"title":"Query Set Id","type":"string"},"search_space":{"additionalProperties":true,"title":"Search Space","type":"object"},"started_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Started At"},"status":{"enum":["queued","running","completed","cancelled","failed"],"title":"Status","type":"string"},"target":{"title":"Target","type":"string"},"template_id":{"title":"Template Id","type":"string"},"trials_summary":{"$ref":"#/components/schemas/TrialsSummaryShape"}},"required":["id","name","cluster_id","target","template_id","query_set_id","judgment_list_id","search_space","objective","config","status","failed_reason","optuna_study_name","parent_study_id","baseline_metric","baseline_trial_id","best_metric","best_trial_id","created_at","started_at","completed_at","trials_summary"],"title":"StudyDetail","type":"object"},"StudyListResponse":{"description":"``GET /api/v1/studies`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/StudySummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"StudyListResponse","type":"object"},"StudySummary":{"description":"List-view shape.","properties":{"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"cluster_id":{"title":"Cluster Id","type":"string"},"completed_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Completed At"},"convergence_verdict":{"anyOf":[{"enum":["converged","still_improving","too_few_trials"],"type":"string"},{"type":"null"}],"title":"Convergence Verdict"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"direction":{"default":"maximize","enum":["maximize","minimize"],"title":"Direction","type":"string"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"status":{"enum":["queued","running","completed","cancelled","failed"],"title":"Status","type":"string"},"trial_count":{"default":0,"title":"Trial Count","type":"integer"}},"required":["id","name","cluster_id","status","best_metric","created_at","completed_at"],"title":"StudySummary","type":"object"},"Subsystems":{"description":"Per-subsystem reachability/configuration state. Wire values per spec §7.4.","properties":{"db":{"description":"Postgres reachability","enum":["ok","down"],"title":"Db","type":"string"},"elasticsearch":{"description":"Local Elasticsearch container reachability","enum":["reachable","unreachable"],"title":"Elasticsearch","type":"string"},"elasticsearch_clusters":{"$ref":"#/components/schemas/ClusterAggregateHealth","description":"Aggregate health of user-registered clusters (infra_adapter_elastic Story 3.5 / spec §2). registered=0 → all-zero counts; informational only — does NOT trigger overall `degraded`."},"openai":{"description":"OpenAI key + capability state. 'incapable' added per FR-2 vs. spec §7.4 enum table — see implementation_plan.md §13 Review log.","enum":["configured","missing_key","incapable"],"title":"Openai","type":"string"},"opensearch":{"description":"Local OpenSearch container reachability","enum":["reachable","unreachable"],"title":"Opensearch","type":"string"},"redis":{"description":"Redis reachability","enum":["ok","down"],"title":"Redis","type":"string"},"solr":{"default":"not_configured","description":"Local Apache Solr container reachability. 'not_configured' when SOLR_HOST is unset (operator opted out of running the Solr service). Added by infra_adapter_solr Story A10 / spec FR-12a.","enum":["reachable","unreachable","not_configured"],"title":"Solr","type":"string"}},"required":["db","redis","openai","elasticsearch","opensearch","elasticsearch_clusters"],"title":"Subsystems","type":"object"},"SwapTemplateFollowup":{"additionalProperties":false,"description":"A 'swap_template' followup — re-run against a different query template.\n\nCarries the LLM-proposed bounds for params shared with the parent template\nin ``search_space``. The digest worker calls\n:func:`backend.app.domain.study.template_swap.remap_search_space_for_swap_target`\nafter parsing to merge these bounds with heuristic defaults for any\nswap-target params not shared with the parent.\n\nOwner: ``feat_digest_executable_followups_swap_template`` (Tier B).","properties":{"kind":{"const":"swap_template","title":"Kind","type":"string"},"rationale":{"title":"Rationale","type":"string"},"search_space":{"$ref":"#/components/schemas/SearchSpace"},"template_id":{"maxLength":36,"minLength":36,"title":"Template Id","type":"string"}},"required":["kind","rationale","template_id","search_space"],"title":"SwapTemplateFollowup","type":"object"},"TargetInfo":{"description":"One target (index / collection) on a cluster.","properties":{"doc_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Doc Count"},"name":{"title":"Name","type":"string"}},"required":["name"],"title":"TargetInfo","type":"object"},"TargetListResponse":{"description":"Response for ``GET /api/v1/clusters/{cluster_id}/targets`` (FR-1).\n\nUnpaginated by design — see feature_spec.md §7.1 \"pagination shape\nrationale\". The single-resource lookup pattern matches\n``/clusters/{id}/schema`` rather than the queryable ``/clusters`` list.\n``EntitySelectListPage``'s ``next_cursor`` and ``has_more`` fields\nare optional, so this bare ``data``-only shape consumes correctly on\nthe frontend without pretending to be a cursor endpoint.","properties":{"data":{"items":{"$ref":"#/components/schemas/TargetInfo"},"title":"Data","type":"array"}},"required":["data"],"title":"TargetListResponse","type":"object"},"TextFollowup":{"additionalProperties":false,"description":"A free-form textual suggestion — no auto-prefill, operator interprets.","properties":{"kind":{"const":"text","title":"Kind","type":"string"},"rationale":{"title":"Rationale","type":"string"},"search_space":{"title":"Search Space","type":"null"}},"required":["kind","rationale"],"title":"TextFollowup","type":"object"},"TrialDetail":{"description":"``GET /api/v1/studies/{id}/trials`` response row.","properties":{"duration_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration Ms"},"ended_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Ended At"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"id":{"title":"Id","type":"string"},"is_baseline":{"default":false,"title":"Is Baseline","type":"boolean"},"metrics":{"additionalProperties":true,"title":"Metrics","type":"object"},"optuna_trial_number":{"title":"Optuna Trial Number","type":"integer"},"params":{"additionalProperties":true,"title":"Params","type":"object"},"primary_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Primary Metric"},"started_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Started At"},"status":{"enum":["complete","failed","pruned"],"title":"Status","type":"string"},"study_id":{"title":"Study Id","type":"string"}},"required":["id","study_id","optuna_trial_number","params","primary_metric","metrics","duration_ms","status","error","started_at","ended_at"],"title":"TrialDetail","type":"object"},"TrialListResponse":{"description":"``GET /api/v1/studies/{id}/trials`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/TrialDetail"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"TrialListResponse","type":"object"},"TrialsSummaryShape":{"description":"The ``trials_summary`` field embedded in :class:`StudyDetail`.","properties":{"best_primary_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Primary Metric"},"complete":{"title":"Complete","type":"integer"},"failed":{"title":"Failed","type":"integer"},"pruned":{"title":"Pruned","type":"integer"},"total":{"title":"Total","type":"integer"}},"required":["total","complete","failed","pruned","best_primary_metric"],"title":"TrialsSummaryShape","type":"object"},"UbiReadinessResponse":{"description":"``GET /api/v1/clusters/{cluster_id}/ubi-readiness`` response (FR-7).\n\n``covered_pairs_pct`` and ``head_covered`` are nullable — MVP2's\nrung classifier uses event-count thresholds (the SearchAdapter\nProtocol doesn't expose an exact ``_count`` endpoint). The fields\nare reserved on the wire so a future ``infra_adapter_count_method``\ncan fill them without breaking the contract. See\n:mod:`backend.app.services.ubi_readiness` for the rationale.","properties":{"checked_at":{"format":"date-time","title":"Checked At","type":"string"},"covered_pairs_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Covered Pairs Pct"},"head_covered":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Head Covered"},"rung":{"enum":["rung_0","rung_1","rung_2","rung_3"],"title":"Rung","type":"string"}},"required":["rung","covered_pairs_pct","head_covered","checked_at"],"title":"UbiReadinessResponse","type":"object"},"UpdateQueryRequest":{"additionalProperties":false,"description":"``PATCH /api/v1/query-sets/{set_id}/queries/{query_id}`` body.\n\nWhole-object replace on ``query_metadata`` (NOT deep-merge); explicit\n``null`` removes a nullable field; omitted key = no change. Empty\nbody ``{}`` validates as a no-op (AC-28).\n\n``query_text`` is NOT NULL on the underlying table, so explicit-null\nis rejected by the ``@model_validator`` below (a 422 surfaces sooner\nthan the SQL ``NotNullViolation``).","properties":{"query_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Query Metadata"},"query_text":{"anyOf":[{"maxLength":4000,"minLength":1,"type":"string"},{"type":"null"}],"title":"Query Text"},"reference_answer":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reference Answer"}},"title":"UpdateQueryRequest","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"},"WidenFollowup":{"additionalProperties":false,"description":"A 'widen' followup — re-run with a broader range than the parent.","properties":{"kind":{"const":"widen","title":"Kind","type":"string"},"rationale":{"title":"Rationale","type":"string"},"search_space":{"$ref":"#/components/schemas/SearchSpace"}},"required":["kind","rationale","search_space"],"title":"WidenFollowup","type":"object"},"_ClusterEmbed":{"description":"Inline cluster summary on proposal responses.","properties":{"engine_type":{"title":"Engine Type","type":"string"},"environment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"}},"required":["id","name","engine_type"],"title":"_ClusterEmbed","type":"object"},"_DigestEmbed":{"description":"Inline digest summary on the proposal-detail response.\n\nfeat_digest_executable_followups Story 4.1 — ``suggested_followups`` is\nnow a discriminated-union list (see ``DigestResponse``).","properties":{"generated_at":{"format":"date-time","title":"Generated At","type":"string"},"id":{"title":"Id","type":"string"},"narrative":{"title":"Narrative","type":"string"},"parameter_importance":{"additionalProperties":{"type":"number"},"title":"Parameter Importance","type":"object"},"recommended_config":{"additionalProperties":true,"title":"Recommended Config","type":"object"},"suggested_followups":{"items":{"$ref":"#/components/schemas/FollowupItem"},"title":"Suggested Followups","type":"array"}},"required":["id","narrative","parameter_importance","recommended_config","suggested_followups","generated_at"],"title":"_DigestEmbed","type":"object"},"_SourceBreakdown":{"description":"Source-breakdown sub-shape on :class:`JudgmentListDetail`.\n\nEvolved 2026-05-29 by ``feat_ubi_judgments`` FR-10 — now three terms\n(``llm + human + click == judgment_count``). The cycle-2 F6\n\"click folds into human\" contract is superseded the moment UBI ships\nclick rows; the UI's source-breakdown card now renders all three\nbuckets separately so operators see the mix at a glance.","properties":{"click":{"title":"Click","type":"integer"},"human":{"title":"Human","type":"integer"},"llm":{"title":"Llm","type":"integer"}},"required":["llm","human","click"],"title":"_SourceBreakdown","type":"object"},"_StudySummary":{"description":"Inline study summary on the proposal-detail response.","properties":{"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"best_trial_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Best Trial Id"},"id":{"title":"Id","type":"string"},"judgment_list":{"additionalProperties":true,"title":"Judgment List","type":"object"},"name":{"title":"Name","type":"string"},"query_set":{"additionalProperties":true,"title":"Query Set","type":"object"},"status":{"title":"Status","type":"string"}},"required":["id","name","status","best_metric","best_trial_id","query_set","judgment_list"],"title":"_StudySummary","type":"object"},"_TemplateEmbed":{"description":"Inline template summary on proposal responses.","properties":{"engine_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Engine Type"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"version":{"title":"Version","type":"integer"}},"required":["id","name","version"],"title":"_TemplateEmbed","type":"object"}}},"info":{"description":"Open-source automated relevance tuning for enterprise search platforms","title":"RelyLoop","version":"0.1.0"},"openapi":"3.1.0","paths":{"/api/v1/_test/auto-followup/seed-chain":{"post":{"description":"Test-only endpoint. Returns 404 unless `ENVIRONMENT=development`. Inserts a chain of `depth + 1` studies where each child carries the prior node's id as `parent_study_id`. The public POST /studies endpoint does NOT accept `parent_study_id` (it's set only by the auto-followup worker via `repo.create_study(parent_study_id=...)`), so this endpoint is the only way to drive deterministic E2E coverage of chain-panel parent-link / children-table / cascade-radio paths. Closes chore_auto_followup_e2e_chain_seed_helper.","operationId":"seed_auto_followup_chain_endpoint_api_v1__test_auto_followup_seed_chain_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeedAutoFollowupChainRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeedAutoFollowupChainResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Seed an auto-followup chain of N+1 linked studies","tags":["test-only"]}},"/api/v1/_test/demo/reseed":{"post":{"description":"Enqueues an Arq job that wipes the demo Postgres tables + ES/OS indices, then re-seeds the 4 demo scenarios from ``scripts/seed_meaningful_demos.py`` using REAL studies (real Optuna trials, real metrics per scenario). Returns 202 + an initial ``ReseedStatusResponse`` immediately; the frontend polls ``GET /api/v1/_test/demo/reseed/status`` for progress.\n\nPer ``bug_demo_reseed_fake_metric_regression``. Replaces the previous synchronous path that called ``/_test/studies/seed-completed`` and produced identical ``best_metric=0.487`` rows for every scenario.","operationId":"reseed_demo_api_v1__test_demo_reseed_post","responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReseedStatusResponse"}}},"description":"Successful Response"}},"summary":"Enqueue a demo-state reseed (dev-only, async)","tags":["test-only"]}},"/api/v1/_test/demo/reseed/status":{"get":{"description":"Returns the current reseed status from Redis. When no reseed has ever run (or the result TTL'd out), returns ``{status: 'idle'}`` rather than 404 so the frontend's polling loop is trivially safe.","operationId":"reseed_demo_status_api_v1__test_demo_reseed_status_get","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReseedStatusResponse"}}},"description":"Successful Response"}},"summary":"Poll the current demo-reseed progress (dev-only)","tags":["test-only"]}},"/api/v1/_test/digests/{digest_id}":{"delete":{"description":"FR-2: Hard-delete the digest row. No FK children — no preflight needed.","operationId":"delete_test_digest_api_v1__test_digests__digest_id__delete","parameters":[{"in":"path","name":"digest_id","required":true,"schema":{"title":"Digest Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a digest (test-only)","tags":["test-only"]}},"/api/v1/_test/judgment-lists/{judgment_list_id}":{"delete":{"description":"FR-4 — hard-delete the judgment_list row.\n\nJudgments cascade-delete via existing FK. Preflight-checks ``studies``\n(non-cascade); 409 if any study references the judgment_list.","operationId":"delete_test_judgment_list_api_v1__test_judgment_lists__judgment_list_id__delete","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a judgment_list (test-only)","tags":["test-only"]}},"/api/v1/_test/proposals/{proposal_id}":{"delete":{"description":"FR-1: Hard-delete the proposal row. No FK children — no preflight needed.","operationId":"delete_test_proposal_api_v1__test_proposals__proposal_id__delete","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a proposal (test-only)","tags":["test-only"]}},"/api/v1/_test/query-sets/{query_set_id}":{"delete":{"description":"FR-5 — hard-delete the query_set row.\n\nQueries cascade-delete via existing FK. Preflight-checks ``studies``\n+ ``judgment_lists`` (both non-cascade); 409 with resource-specific\ncode if either references.","operationId":"delete_test_query_set_api_v1__test_query_sets__query_set_id__delete","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a query_set (test-only)","tags":["test-only"]}},"/api/v1/_test/query-templates/{template_id}":{"delete":{"description":"FR-6 — hard-delete the query_template row.\n\nNo FK children cascade with template. Preflight-checks ``studies``,\n``proposals``, and ``judgment_lists.current_template_id`` in\n**fixed priority order: STUDY > PROPOSAL > JUDGMENT_LIST** (per\nspec §FR-6) — first match wins.","operationId":"delete_test_query_template_api_v1__test_query_templates__template_id__delete","parameters":[{"in":"path","name":"template_id","required":true,"schema":{"title":"Template Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a query_template (test-only)","tags":["test-only"]}},"/api/v1/_test/studies/seed-completed":{"post":{"description":"Test-only endpoint. Returns 404 unless `ENVIRONMENT=development`. Inserts a study (driven through queued → running → completed via the legal state-machine transitions), 2 trials (one winner, one comparison), a digest, and optionally a pending proposal in a single transaction. Used by the Playwright E2E suite to cover the digest-panel surfaces (7 tooltip placements + AC-7 body content + AC-11 Open PR enabled/disabled branches) without waiting on the orchestrator + Optuna workers.","operationId":"seed_completed_study_api_v1__test_studies_seed_completed_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeedCompletedStudyRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeedCompletedStudyResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Seed a completed study + digest + (optional) pending proposal","tags":["test-only"]}},"/api/v1/_test/studies/{study_id}":{"delete":{"description":"FR-3 — hard-delete the study row.\n\nTrials cascade-delete via existing FK. Preflight-checks ``proposals``\n+ ``digests`` (both non-cascade); 409 if any dependent rows reference\nthe study.","operationId":"delete_test_study_api_v1__test_studies__study_id__delete","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a study (test-only)","tags":["test-only"]}},"/api/v1/clusters":{"get":{"description":"List clusters with cursor pagination + ``X-Total-Count`` header.\n\n``?q=`` is a Postgres FTS match against the cluster's ``search_vector``\n(name + base_url); 2–200 chars. Filter-only — ordering unchanged per\nspec FR-1. ``?sort=`` is one of the values in\n:data:`~backend.app.api.v1.schemas.ClusterSortKey`; the cursor is\nsort-aware so the keyset predicate matches the active ORDER BY\n(feat_data_table_primitive Stories 1.2 + 1.3).","operationId":"list_clusters_api_v1_clusters_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc","environment:asc","environment:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}},{"in":"query","name":"engine_type","required":false,"schema":{"anyOf":[{"enum":["elasticsearch","opensearch","solr"],"type":"string"},{"type":"null"}],"title":"Engine Type"}},{"in":"query","name":"environment","required":false,"schema":{"anyOf":[{"enum":["prod","staging","dev"],"type":"string"},{"type":"null"}],"title":"Environment"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Clusters","tags":["clusters"]},"post":{"description":"Register a cluster (FR-5 / AC-1).","operationId":"create_cluster_api_v1_clusters_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateClusterRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Cluster","tags":["clusters"]}},"/api/v1/clusters/test-connection":{"post":{"description":"Probe a cluster config WITHOUT persisting (infra_adapter_solr Story A9).\n\nPowers the registration modal's \"Test connection\" button. Always 200 —\ntransport failures surface as ``reachable=false`` with ``error`` set.\nInvalid engine×auth pairings 400 BEFORE the network call.","operationId":"test_connection_api_v1_clusters_test_connection_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectionTestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectionTestResult"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Test Connection","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}":{"delete":{"description":"Soft-delete a cluster (AC-8). Returns 204 with no body.","operationId":"delete_cluster_api_v1_clusters__cluster_id__delete","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Delete Cluster","tags":["clusters"]},"get":{"description":"Return cluster row + cached/fresh health probe.","operationId":"get_cluster_detail_api_v1_clusters__cluster_id__get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Cluster Detail","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/reprobe":{"post":{"description":"Re-run cluster capability probe (Story A9 / spec FR-2 + AC-14).\n\nConcurrent calls serialize on ``SELECT … FOR UPDATE``. On probe failure\nthe row's engine_config is NOT updated (the transaction rolls back).","operationId":"reprobe_cluster_api_v1_clusters__cluster_id__reprobe_post","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Reprobe Cluster","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/run_query":{"post":{"description":"Execute one query DSL fragment against the cluster (FR-6 / AC-3).","operationId":"run_query_api_v1_clusters__cluster_id__run_query_post","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"query","name":"timeout_s","required":false,"schema":{"default":5.0,"maximum":30.0,"minimum":1.0,"title":"Timeout S","type":"number"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunQueryRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunQueryResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Run Query","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/schema":{"get":{"description":"Return the field schema for ``target`` (FR-4 / AC-2).","operationId":"get_cluster_schema_api_v1_clusters__cluster_id__schema_get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"query","name":"target","required":true,"schema":{"maxLength":256,"minLength":1,"title":"Target","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Schema"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Cluster Schema","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/targets":{"get":{"description":"List targets (indices/collections) on the cluster (FR-1 / AC-1).\n\nThin passthrough to ``ElasticAdapter.list_targets()`` (which filters out\nsystem indices whose names start with ``.``). Mirrors the ``get_cluster_schema``\npattern: ``get_cluster`` → ``acquire_adapter`` async context → adapter call\n→ translate exceptions via the ``_err()`` helper to the spec §7.5 envelope.\n\nError mapping:\n* cluster missing or soft-deleted → 404 ``CLUSTER_NOT_FOUND`` (retryable=false)\n* adapter raises ``TargetsForbiddenError`` (ACL 401/403) → 403\n ``TARGETS_FORBIDDEN`` (retryable=false) — frontend auto-engages manual mode\n* adapter raises ``ClusterUnreachableError`` (5xx / connection failure) → 503\n ``CLUSTER_UNREACHABLE`` (retryable=true)","operationId":"list_cluster_targets_api_v1_clusters__cluster_id__targets_get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TargetListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Cluster Targets","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/targets/{target}/documents":{"get":{"description":"Paginated _id + truncated _source preview for a target (FR-3).\n\nThe endpoint asks the adapter for ``limit + 1`` rows so it can detect\nend-of-data exactly (no extra round-trip). Only the first ``limit`` rows\nare returned; ``next_cursor`` encodes the ES ``hits[i].sort`` of the\nlast visible row when ``has_more`` is True. ``X-Total-Count`` header\ncarries the engine's ``hits.total.value``.","operationId":"list_target_documents_api_v1_clusters__cluster_id__targets__target__documents_get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"path","name":"target","required":true,"schema":{"title":"Target","type":"string"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"maxLength":4096,"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":25,"maximum":100,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"fields","required":false,"schema":{"anyOf":[{"maxLength":2048,"type":"string"},{"type":"null"}],"title":"Fields"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Target Documents","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/targets/{target}/documents/{doc_id}":{"get":{"description":"Fetch one document by ``_id`` (FR-4).\n\nFastAPI's ``{doc_id:path}`` converter round-trips slashes verbatim, so\noperator IDs containing ``/`` are supported (D-17 / AC-16). Returns the\nadapter ``Document`` shape directly; on ``found: false`` returns 404\n``DOCUMENT_NOT_FOUND`` (distinct from ``TARGET_NOT_FOUND``).","operationId":"get_target_document_api_v1_clusters__cluster_id__targets__target__documents__doc_id__get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"path","name":"target","required":true,"schema":{"title":"Target","type":"string"}},{"in":"path","name":"doc_id","required":true,"schema":{"title":"Doc Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Target Document","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/ubi-readiness":{"get":{"description":"Classify ``(cluster, query_set, target)`` on the UBI rung ladder.\n\nfeat_ubi_judgments FR-7.\n\nRequired query params: ``query_set_id`` + ``target`` (Spec FR-7 +\ncycle-3 D-10c: the endpoint MUST 422 without them — the classifier\ncan't compute a per-target rung without an application filter).\n\nError envelopes (all per spec §7.5):\n* ``404 CLUSTER_NOT_FOUND`` — cluster row missing or soft-deleted.\n* ``404 QUERY_SET_NOT_FOUND`` — query set row missing.\n* ``422 VALIDATION_ERROR`` — missing required query params (FastAPI's\n built-in handler, surfaces via ``api/errors.py``).\n* ``503 CLUSTER_UNREACHABLE`` — adapter cannot reach the cluster.\n\nThe result is cached for 60 s in Redis per\n``(cluster_id, query_set_id, target)`` so back-to-back dialog-open\nand dialog-submit calls don't re-probe.","operationId":"get_cluster_ubi_readiness_api_v1_clusters__cluster_id__ubi_readiness_get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"query","name":"query_set_id","required":true,"schema":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"}},{"in":"query","name":"target","required":true,"schema":{"maxLength":256,"minLength":1,"title":"Target","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UbiReadinessResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Cluster Ubi Readiness","tags":["clusters"]}},"/api/v1/config-repos":{"get":{"description":"Cursor-paginated config-repo list, newest first.","operationId":"list_config_repos_endpoint_api_v1_config_repos_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigReposListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Config Repos Endpoint","tags":["config-repos"]},"post":{"description":"Register a new config repo. ``provider`` is server-derived from ``repo_url``.\n\nPreflight order matches spec FR-3:\n\n1. ``validate_repo_url(repo_url)`` → 400 ``UNSUPPORTED_PROVIDER`` for\n non-GitHub URLs (AC-8). GitLab + Bitbucket arrive at MVP3.\n2. ``./secrets/{auth_ref}`` must exist → else 400 ``AUTH_REF_NOT_FOUND``\n (AC-9). The contents check defers to the worker — operators may\n populate the file between registration and first PR-open.\n3. ``name`` uniqueness check → 409 ``CONFIG_REPO_NAME_TAKEN`` on collision.\n4. Insert with server-derived ``provider=\"github\"``.\n5. **feat_github_webhook Story 4.2** — when ``webhook_secret_ref`` is\n populated, best-effort enqueue ``register_webhook`` against the\n newly created config_repo id. Enqueue failure (Redis down, pool\n absent, transient blip) does NOT break the 201 — it logs WARN\n and the operator drives recovery via the runbook.","operationId":"create_config_repo_endpoint_api_v1_config_repos_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateConfigRepoRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigRepoDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Config Repo Endpoint","tags":["config-repos"]}},"/api/v1/config-repos/{config_repo_id}":{"get":{"description":"Detail by id; 404 ``CONFIG_REPO_NOT_FOUND`` if missing.\n\nfeat_config_repo_baseline_tracking FR-4 — when\n``last_merged_proposal_id`` is set, embed the pointed-at proposal as a\n:class:`ProposalSummary` with ``is_currently_live=True``. The embed-side\nderivation uses the pointer context directly (NOT the generic\n``proposals → clusters → config_repos`` JOIN used elsewhere) so the\nbadge renders correctly even when the proposal's cluster was later\nunwired from this config_repo (spec §19 \"Cluster-with-config_repo-\nrotated\" decision-log entry).","operationId":"get_config_repo_endpoint_api_v1_config_repos__config_repo_id__get","parameters":[{"in":"path","name":"config_repo_id","required":true,"schema":{"title":"Config Repo Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigRepoDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Config Repo Endpoint","tags":["config-repos"]}},"/api/v1/conversations":{"get":{"description":"List conversations newest-first with per-row message_count + X-Total-Count header.\n\n``?since=`` (Story 1.5 — closes api-conventions.md drift) filters by\n``created_at >= since``. ``?q=`` (Story 1.2) is a Postgres FTS match\nagainst ``search_vector`` (coalesce(title, '')); 2-200 chars.","operationId":"list_conversations_endpoint_api_v1_conversations_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationsListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Conversations Endpoint","tags":["conversations"]},"post":{"description":"Create a new conversation. Title is optional (FR-1 auto-generates from first message).","operationId":"create_conversation_endpoint_api_v1_conversations_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateConversationRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationSummary"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Conversation Endpoint","tags":["conversations"]}},"/api/v1/conversations/{conversation_id}":{"delete":{"description":"Soft-delete the conversation; subsequent reads return 404.","operationId":"delete_conversation_endpoint_api_v1_conversations__conversation_id__delete","parameters":[{"in":"path","name":"conversation_id","required":true,"schema":{"title":"Conversation Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Delete Conversation Endpoint","tags":["conversations"]},"get":{"description":"Return the conversation's full message history.","operationId":"get_conversation_endpoint_api_v1_conversations__conversation_id__get","parameters":[{"in":"path","name":"conversation_id","required":true,"schema":{"title":"Conversation Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Conversation Endpoint","tags":["conversations"]}},"/api/v1/conversations/{conversation_id}/messages":{"post":{"description":"Send a user message and stream the assistant turn as SSE.\n\nPreflight (in order; returns plain JSON envelope, NOT a partial stream):\n A. Conversation exists → else 404 ``CONVERSATION_NOT_FOUND``.\n B. ``Settings.openai_api_key`` populated → else 503 ``OPENAI_NOT_CONFIGURED``.\n C. Daily budget peek under cap → else 503 ``OPENAI_BUDGET_EXCEEDED``.\n\nSuccessful preflight returns a ``StreamingResponse(text/event-stream)``\ndriven by :func:`agent_chat.send_user_message`.","operationId":"post_message_endpoint_api_v1_conversations__conversation_id__messages_post","parameters":[{"in":"path","name":"conversation_id","required":true,"schema":{"title":"Conversation Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Post Message Endpoint","tags":["conversations"]}},"/api/v1/judgment-lists":{"get":{"description":"List judgment lists, newest-first with cursor pagination.\n\n``?since=`` filters by ``created_at >= since`` (Story 1.5). ``?q=`` FTS\nmatch against ``search_vector`` (name + target). ``?sort=`` is a\n:data:`JudgmentListSortKey` value with sort-aware cursor (Story 1.3).\n``?query_set_id`` / ``?cluster_id`` filter to lists belonging to the\nsupplied parent (``bug_judgment_lists_listing_ignores_query_set_filter``\n— required by the create-study modal's Step-2 dropdown so the user\ncan only pick judgment-lists valid for the chosen query-set + cluster;\nwithout these filters the modal returns all rows and the user can\npick a mismatched pair, which the ``POST /api/v1/studies`` cross-\nentity integrity check then rejects at create time with a confusing\n422 ``VALIDATION_ERROR: \"judgment_list query_set_id does not match\nstudy query_set_id\"``).\n\n``?target=`` filters by exact target index/collection name\n(``feat_study_target_judgment_mismatch_guard`` FR-2 — pairs with the\n``POST /studies`` ``JUDGMENT_TARGET_MISMATCH`` 422 so the create-study\nmodal can pre-filter the dropdown to only lists matching the chosen\nstudy target). Bounded by the ES/OpenSearch index-name ceiling\n(255 bytes).","operationId":"list_judgment_lists_endpoint_api_v1_judgment_lists_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc","status:asc","status:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}},{"in":"query","name":"query_set_id","required":false,"schema":{"anyOf":[{"maxLength":36,"minLength":1,"type":"string"},{"type":"null"}],"title":"Query Set Id"}},{"in":"query","name":"cluster_id","required":false,"schema":{"anyOf":[{"maxLength":36,"minLength":1,"type":"string"},{"type":"null"}],"title":"Cluster Id"}},{"in":"query","name":"target","required":false,"schema":{"anyOf":[{"maxLength":255,"minLength":1,"type":"string"},{"type":"null"}],"title":"Target"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentListListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Judgment Lists Endpoint","tags":["judgments"]}},"/api/v1/judgment-lists/import":{"post":{"description":"Create a judgment_lists row with status='complete' + bulk-insert judgments.\n\nTutorial path; no OpenAI involvement. Every supplied judgment must\nreference a ``query_id`` that exists in ``body.query_set_id`` —\nmismatches → 400 ``QUERY_NOT_IN_SET``.","operationId":"import_judgment_list_api_v1_judgment_lists_import_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportJudgmentListRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentListDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Import Judgment List","tags":["judgments"]}},"/api/v1/judgment-lists/{judgment_list_id}":{"get":{"operationId":"get_judgment_list_endpoint_api_v1_judgment_lists__judgment_list_id__get","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentListDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Judgment List Endpoint","tags":["judgments"]}},"/api/v1/judgment-lists/{judgment_list_id}/calibration":{"post":{"description":"Compute Cohen's + weighted kappa from supplied human samples.\n\nPairs are built by joining each sample with the existing\n``source='llm'`` judgment at ``(query_id, doc_id)`` — overridden rows\n(``source='human'``) are excluded (per spec FR-5 + GPT-5.5 cycle 1 F12).","operationId":"calibrate_judgment_list_api_v1_judgment_lists__judgment_list_id__calibration_post","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalibrationSamplesRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalibrationResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Calibrate Judgment List","tags":["judgments"]}},"/api/v1/judgment-lists/{judgment_list_id}/judgments":{"get":{"description":"List per-list judgments with cursor pagination.\n\n``?sort=`` is :data:`JudgmentRowSortKey` with sort-aware cursor\n(feat_data_table_primitive Story 1.3).","operationId":"list_judgments_endpoint_api_v1_judgment_lists__judgment_list_id__judgments_get","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}},{"in":"query","name":"source","required":false,"schema":{"anyOf":[{"enum":["llm","human","click"],"type":"string"},{"type":"null"}],"title":"Source"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["created_at:asc","created_at:desc","rating:asc","rating:desc","source:asc","source:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentListJudgmentsResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Judgments Endpoint","tags":["judgments"]}},"/api/v1/judgment-lists/{judgment_list_id}/judgments/{judgment_id}":{"patch":{"description":"Replace an LLM rating with a human override (UPSERT-replace).","operationId":"override_judgment_api_v1_judgment_lists__judgment_list_id__judgments__judgment_id__patch","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}},{"in":"path","name":"judgment_id","required":true,"schema":{"title":"Judgment Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OverrideJudgmentRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentRow"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Override Judgment","tags":["judgments"]}},"/api/v1/judgments/generate":{"post":{"description":"Create a judgment_lists row + enqueue the worker.\n\nDelegates the full preflight + INSERT + Arq enqueue to\n:func:`backend.app.services.agent_judgments_dispatch.start_judgment_generation`\nso the chat-agent ``generate_judgments_llm`` tool reuses the exact same\nchecks (no duplicated preflight). Wire behavior is identical — same error\ncodes, same status codes, same response shape.","operationId":"generate_judgments_api_v1_judgments_generate_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJudgmentListGenerateRequest"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateJudgmentsResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Generate Judgments","tags":["judgments"]}},"/api/v1/judgments/generate-from-ubi":{"post":{"description":"Start a UBI-derived judgment generation job.\n\nDelegates to\n:func:`backend.app.services.agent_judgments_dispatch.start_ubi_judgment_generation`\nwhich runs the full FR-4 preflight (U-A..U-H) before INSERT + Arq\nenqueue. The Pydantic ``model_validator`` on\n:class:`CreateJudgmentListFromUbiRequest` already enforces the\nhybrid conditional (``current_template_id`` + ``rubric`` required\niff ``converter == 'hybrid_ubi_llm'``); the dispatcher trusts the\nvalidated request.","operationId":"generate_judgments_from_ubi_api_v1_judgments_generate_from_ubi_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJudgmentListFromUbiRequest"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateJudgmentsResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Generate Judgments From Ubi","tags":["judgments"]}},"/api/v1/proposals":{"get":{"description":"List proposals with cursor pagination + filters.\n\n``?template_id=`` (Story 1.5) filters by ``proposals.template_id`` FK;\n``?study_id=`` filters by ``proposals.study_id`` FK (used by the\nstudy-detail page's pending-proposal lookup). Both reject invalid\nUUIDs with 422 via FastAPI's UUID parsing. ``?sort=`` (Story 1.3) is\na :data:`ProposalSortKey` value with sort-aware cursor.","operationId":"list_proposals_endpoint_api_v1_proposals_get","parameters":[{"in":"query","name":"status","required":false,"schema":{"anyOf":[{"enum":["pending","pr_opened","pr_merged","rejected"],"type":"string"},{"type":"null"}],"title":"Status"}},{"in":"query","name":"cluster_id","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cluster Id"}},{"in":"query","name":"source","required":false,"schema":{"anyOf":[{"enum":["study","manual"],"type":"string"},{"type":"null"}],"title":"Source"}},{"in":"query","name":"template_id","required":false,"schema":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}],"title":"Template Id"}},{"in":"query","name":"study_id","required":false,"schema":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}],"title":"Study Id"}},{"in":"query","name":"is_last_merged","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Last Merged"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["created_at:asc","created_at:desc","status:asc","status:desc","pr_state:asc","pr_state:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalsListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Proposals Endpoint","tags":["proposals"]},"post":{"description":"Manually create a proposal (chat-agent hand-crafted tweaks).\n\n``study_id`` and ``study_trial_id`` are NULL for manual proposals.\nValidates FK targets (cluster + template exist) before insert.","operationId":"create_manual_proposal_api_v1_proposals_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProposalRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Manual Proposal","tags":["proposals"]}},"/api/v1/proposals/{proposal_id}":{"get":{"operationId":"get_proposal_endpoint_api_v1_proposals__proposal_id__get","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Proposal Endpoint","tags":["proposals"]}},"/api/v1/proposals/{proposal_id}/open_pr":{"post":{"description":"Enqueue the ``open_pr`` worker for an operator-approved proposal.\n\nDelegates the full preflight + Arq enqueue to\n:func:`backend.app.services.agent_proposals_dispatch.open_pr` so the\nchat-agent ``open_pr`` tool reuses the same checks. Wire behavior is\nidentical — same error codes, status codes, response shape.","operationId":"open_pr_endpoint_api_v1_proposals__proposal_id__open_pr_post","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenPrResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Open Pr Endpoint","tags":["proposals"]}},"/api/v1/proposals/{proposal_id}/reject":{"post":{"description":"AC-5: ``pending → rejected`` transition; 409 INVALID_STATE_TRANSITION otherwise.","operationId":"reject_proposal_endpoint_api_v1_proposals__proposal_id__reject_post","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectProposalRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Reject Proposal Endpoint","tags":["proposals"]}},"/api/v1/query-sets":{"get":{"description":"List query sets with cursor pagination + X-Total-Count.\n\n``?q=`` is FTS match against ``search_vector`` (name). ``?sort=`` is a\n:data:`QuerySetSortKey` value; cursor is sort-aware.","operationId":"list_query_sets_api_v1_query_sets_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuerySetListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Query Sets","tags":["query-sets"]},"post":{"description":"Register a query set under a cluster (FR-3).","operationId":"create_query_set_api_v1_query_sets_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuerySetRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuerySetDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Query Set","tags":["query-sets"]}},"/api/v1/query-sets/{query_set_id}":{"get":{"description":"Return a query set by id (includes ``query_count``).","operationId":"get_query_set_detail_api_v1_query_sets__query_set_id__get","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuerySetDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Query Set Detail","tags":["query-sets"]}},"/api/v1/query-sets/{query_set_id}/queries":{"get":{"description":"List per-query rows under a query set, with derived ``judgment_count``.","operationId":"list_queries_in_set_api_v1_query_sets__query_set_id__queries_get","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Queries In Set","tags":["query-sets"]},"post":{"description":"Bulk-add queries to a set (FR-3 + AC-8).\n\nDispatches on Content-Type:\n\n* ``application/json`` → :class:`BulkQueriesJsonRequest` Pydantic-parse.\n* ``text/csv`` → :func:`parse_queries_csv` (AC-8).\n\nOther content types → 415-equivalent surfaced as 400 ``INVALID_CSV``\n(the documented error code for content-type-mismatch in spec §7.5).","operationId":"bulk_add_queries_api_v1_query_sets__query_set_id__queries_post","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}}],"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkQueriesResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Bulk Add Queries","tags":["query-sets"]}},"/api/v1/query-sets/{query_set_id}/queries/{query_id}":{"delete":{"description":"Hard-delete a query. FK-guarded — 409 if any judgment references it.","operationId":"delete_query_endpoint_api_v1_query_sets__query_set_id__queries__query_id__delete","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}},{"in":"path","name":"query_id","required":true,"schema":{"title":"Query Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryHasJudgmentsEnvelope"}}},"description":"Conflict"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Delete Query Endpoint","tags":["query-sets"]},"patch":{"description":"Partial-update a query. Whole-object replace on ``query_metadata``.","operationId":"update_query_endpoint_api_v1_query_sets__query_set_id__queries__query_id__patch","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}},{"in":"path","name":"query_id","required":true,"schema":{"title":"Query Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQueryRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRow"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Update Query Endpoint","tags":["query-sets"]}},"/api/v1/query-templates":{"get":{"description":"List query templates with cursor pagination + X-Total-Count header.\n\n``?q=`` FTS match (name). ``?sort=`` sort-aware cursor (Story 1.3).\n``?engine_type=`` filters by engine (Story 1.4).","operationId":"list_query_templates_api_v1_query_templates_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc","engine_type:asc","engine_type:desc","version:asc","version:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}},{"in":"query","name":"engine_type","required":false,"schema":{"anyOf":[{"enum":["elasticsearch","opensearch","solr"],"type":"string"},{"type":"null"}],"title":"Engine Type"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryTemplateListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Query Templates","tags":["query-templates"]},"post":{"description":"Register a query template (FR-2 + AC-7).\n\nAC-7: a body containing ``{{ os.system('rm -rf /') }}`` surfaces as\n400 ``INVALID_TEMPLATE_SYNTAX`` (the AST walk catches the ``Call``\nnode before reaching the meta-vars cross-check that would otherwise\nclassify ``os`` as ``UndeclaredParamUsed``).","operationId":"create_query_template_api_v1_query_templates_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQueryTemplateRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryTemplateDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Query Template","tags":["query-templates"]}},"/api/v1/query-templates/{template_id}":{"get":{"description":"Return a query template by id.","operationId":"get_query_template_detail_api_v1_query_templates__template_id__get","parameters":[{"in":"path","name":"template_id","required":true,"schema":{"title":"Template Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryTemplateDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Query Template Detail","tags":["query-templates"]}},"/api/v1/studies":{"get":{"description":"List studies with cursor pagination + X-Total-Count.\n\n``?status=`` is typed as :data:`StudyStatusWire` so FastAPI returns\n422 ``VALIDATION_ERROR`` for unsupported values. ``?q=`` is a Postgres\nFTS match against ``search_vector`` (name + target). ``?sort=`` is a\n:data:`StudySortKey` value (``:``); the cursor is\nsort-aware (feat_data_table_primitive Stories 1.2 + 1.3).\n\n``?target=`` (feat_index_document_browser FR-5) scopes the list to\nstudies targeting a single index/collection. Composes with all other\nfilters via AND.","operationId":"list_studies_api_v1_studies_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"status","required":false,"schema":{"anyOf":[{"enum":["queued","running","completed","cancelled","failed"],"type":"string"},{"type":"null"}],"title":"Status"}},{"in":"query","name":"cluster_id","required":false,"schema":{"anyOf":[{"maxLength":36,"minLength":1,"type":"string"},{"type":"null"}],"title":"Cluster Id"}},{"in":"query","name":"target","required":false,"schema":{"anyOf":[{"maxLength":256,"minLength":1,"type":"string"},{"type":"null"}],"title":"Target"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc","completed_at:asc","completed_at:desc","best_metric:asc","best_metric:desc","status:asc","status:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Studies","tags":["studies"]},"post":{"description":"Create a study (FR-1 + AC-1) and enqueue the orchestrator job.","operationId":"create_study_api_v1_studies_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateStudyRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Study","tags":["studies"]}},"/api/v1/studies/chains/recent":{"get":{"description":"List recently-completed overnight chains (FR-1, AC-1/2/3/4/5/6/11/12).\n\nReturns the deduplicated set of completed overnight chains (length\n>= 2) ordered newest-tail-completion-first, capped at ``limit``. The\n``since`` filter restricts to chains whose tail completed at or\nafter the cutoff (used by the card to seed the \"what's new since I\nlast visited\" query).\n\nMalformed ``since`` / out-of-range ``limit`` flow through the\nglobal ``validation_exception_handler`` and return the canonical\n422 ``VALIDATION_ERROR`` envelope (no manual parse path).\n\nPagination: inert. ``next_cursor=null`` and ``has_more=false``\nalways — OQ-2 resolved limit-cap-only for v1. Keyset pagination\ndeferred to a separate ``chore_`` idea filed against the spec's\nopen questions.","operationId":"get_recent_chains_api_v1_studies_chains_recent_get","parameters":[{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"limit","required":false,"schema":{"default":20,"maximum":50,"minimum":1,"title":"Limit","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecentChainsResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Recent Chains","tags":["studies"]}},"/api/v1/studies/{study_id}":{"get":{"description":"Return a study by id (includes ``trials_summary``).","operationId":"get_study_detail_api_v1_studies__study_id__get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Study Detail","tags":["studies"]}},"/api/v1/studies/{study_id}/cancel":{"post":{"description":"Cancel a study (Story 2.3, FR-8 + AC-8/AC-9).\n\nOptionally cascades to in-flight chain children.\n\n``?cascade=true`` (default): routes through\n:func:`services.study_state.cancel_study_with_chain_cascade` —\ncancels the parent (if in-flight) AND recursively cancels in-flight\ndescendants. Tolerates terminal parents (recurses through completed\nintermediates to reach an in-flight grandchild).\n\n``?cascade=false``: routes through the original\n:func:`services.study_state.cancel_study` — single-study cancel,\npreserves the existing 409 error contract on terminal parents\n(AC-9 wire contract).","operationId":"cancel_study_api_v1_studies__study_id__cancel_post","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}},{"in":"query","name":"cascade","required":false,"schema":{"default":"true","title":"Cascade","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Cancel Study","tags":["studies"]}},"/api/v1/studies/{study_id}/chain":{"get":{"description":"Return the rolled-up chain summary for the study and its lineage (FR-3).\n\nWalks to the chain anchor, aggregates the completed-link subset into a\nbest link + cumulative lift + derived stop reason, and emits per-link\ndeltas. The anchor's ``delta_from_prev`` is always ``None`` (spec §8.3).\nReturns ``404 STUDY_NOT_FOUND`` when the study does not exist.","operationId":"get_study_chain_api_v1_studies__study_id__chain_get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyChainResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Study Chain","tags":["studies"]}},"/api/v1/studies/{study_id}/children":{"get":{"description":"List direct child studies of a parent (FR-10 + D-13).\n\nReturns ``{\"data\": [], \"next_cursor\": null}`` for a study with no\nchildren — empty data array, NOT 404. 404 only fires when the parent\nstudy itself is missing.\n\nPer D-13 (direct-children-only): does NOT return transitive\ndescendants. The chain panel renders parent ↑ + direct children ↓;\noperators walk lineage one hop per page navigation.","operationId":"list_study_children_api_v1_studies__study_id__children_get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Study Children","tags":["studies"]}},"/api/v1/studies/{study_id}/digest":{"get":{"description":"Fetch the digest for a completed study.\n\nReturns 404 ``DIGEST_NOT_READY`` (``retryable=true``) when:\n- the study is not in ``status='completed'``, OR\n- the study is completed but the worker hasn't written the digest yet\n (worker lag, or a worker-side terminal failure like\n ``OPENAI_NOT_CONFIGURED`` deferred the run).","operationId":"get_study_digest_api_v1_studies__study_id__digest_get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DigestResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Study Digest","tags":["digests"]}},"/api/v1/studies/{study_id}/trials":{"get":{"description":"List trials in a study (FR-6).\n\nSort variants per spec §7.4: ``primary_metric_desc`` (default),\n``primary_metric_asc``, ``ended_at_desc``, ``ended_at_asc``,\n``optuna_trial_number_asc``.","operationId":"list_study_trials_api_v1_studies__study_id__trials_get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"sort","required":false,"schema":{"default":"primary_metric_desc","title":"Sort","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrialListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Study Trials","tags":["trials"]}},"/healthz":{"get":{"description":"Probe each subsystem in parallel and return the documented JSON shape.\n\nArgs:\n settings: Application settings (DB URL, ES/OS URLs, OpenAI base URL, etc.)\n redis_client: Redis client for ping probe + capability-cache read\n es_client: shared httpx client for ES + OpenSearch HTTP probes\n db: Async DB session for the registered-clusters aggregate (Story 3.5)\n\nReturns:\n JSONResponse with the HealthResponse body and HTTP 200 (healthy) or 503 (degraded).","operationId":"healthz_healthz_get","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}},"description":"Successful Response"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}},"description":"One or more required subsystems is down"}},"summary":"Healthz","tags":["operator"]}},"/webhooks/github":{"post":{"description":"Receive a single GitHub webhook delivery.\n\nReturns ``{\"status\": \"ok\", \"action\": }`` where\n``wire_action`` is one of the four values in\n:data:`WEBHOOK_ACTION_VALUES`.\n\nRaises:\n HTTPException(403, INVALID_SIGNATURE): bad signature or unknown\n repository. Both share one error code so the receiver does\n not reveal repo enumeration.","operationId":"github_webhook_webhooks_github_post","responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"title":"Response Github Webhook Webhooks Github Post","type":"object"}}},"description":"Successful Response"}},"summary":"Github Webhook","tags":["webhooks"]}}}} +{"components":{"schemas":{"BulkQueriesResponse":{"description":"``POST /api/v1/query-sets/{id}/queries`` response.","properties":{"added":{"title":"Added","type":"integer"}},"required":["added"],"title":"BulkQueriesResponse","type":"object"},"CIShape":{"description":"Bootstrap percentile CI on the winner's per-query metric values.","properties":{"high":{"title":"High","type":"number"},"low":{"title":"Low","type":"number"},"method":{"const":"bootstrap_n1000","title":"Method","type":"string"},"n_samples":{"title":"N Samples","type":"integer"}},"required":["low","high","method","n_samples"],"title":"CIShape","type":"object"},"CalibrationResponse":{"description":"Calibration endpoint response.\n\nMirrors :class:`backend.app.eval.calibration.CalibrationResult` —\npersisted as ``judgment_lists.calibration`` JSONB.","properties":{"cohens_kappa":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cohens Kappa"},"n_samples":{"title":"N Samples","type":"integer"},"per_class":{"additionalProperties":{"type":"number"},"title":"Per Class","type":"object"},"warning":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Warning"},"weighted_kappa":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Weighted Kappa"}},"required":["cohens_kappa","weighted_kappa","per_class","n_samples","warning"],"title":"CalibrationResponse","type":"object"},"CalibrationSample":{"description":"One row in :class:`CalibrationSamplesRequest`.","properties":{"doc_id":{"maxLength":512,"minLength":1,"title":"Doc Id","type":"string"},"query_id":{"maxLength":36,"minLength":1,"title":"Query Id","type":"string"},"rating":{"enum":[0,1,2,3],"title":"Rating","type":"integer"}},"required":["query_id","doc_id","rating"],"title":"CalibrationSample","type":"object"},"CalibrationSamplesRequest":{"description":"Body for ``POST /api/v1/judgment-lists/{id}/calibration`` (Story 3.5).","properties":{"human_samples":{"items":{"$ref":"#/components/schemas/CalibrationSample"},"minItems":1,"title":"Human Samples","type":"array"}},"required":["human_samples"],"title":"CalibrationSamplesRequest","type":"object"},"CategoricalParam":{"additionalProperties":false,"description":"Discrete choice parameter.\n\nOptuna ``suggest_categorical`` handles strings, ints, floats, and bools\nas choices.","properties":{"choices":{"items":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"}]},"minItems":1,"title":"Choices","type":"array"},"type":{"const":"categorical","title":"Type","type":"string"}},"required":["type","choices"],"title":"CategoricalParam","type":"object"},"ClusterAggregateHealth":{"description":"Aggregate counts for the ``elasticsearch_clusters`` /healthz field (Story 3.5).\n\nPer spec §2: probes only the *registered* user clusters (from the DB),\nNOT the local Compose ES/OpenSearch — those have their own subsystem\nfields. ``status`` is a count derived from the cached ``cluster:health:*``\nentries; missing-cache or red/unreachable clusters are counted as\n``unreachable``.","properties":{"healthy":{"title":"Healthy","type":"integer"},"registered":{"title":"Registered","type":"integer"},"unreachable":{"title":"Unreachable","type":"integer"}},"required":["registered","healthy","unreachable"],"title":"ClusterAggregateHealth","type":"object"},"ClusterDetail":{"description":"``GET /api/v1/clusters/{id}`` response.","properties":{"auth_kind":{"enum":["es_apikey","es_basic","opensearch_basic","opensearch_sigv4","solr_basic","solr_apikey"],"title":"Auth Kind","type":"string"},"base_url":{"title":"Base Url","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"engine_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Engine Config"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"environment":{"enum":["prod","staging","dev"],"title":"Environment","type":"string"},"health_check":{"$ref":"#/components/schemas/HealthCheckResult"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes"},"target_filter":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Filter"}},"required":["id","name","engine_type","environment","base_url","auth_kind","created_at","health_check"],"title":"ClusterDetail","type":"object"},"ClusterListResponse":{"description":"Paginated list response.","properties":{"data":{"items":{"$ref":"#/components/schemas/ClusterSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"ClusterListResponse","type":"object"},"ClusterSummary":{"description":"List-view; drops engine_config + notes for brevity.","properties":{"auth_kind":{"enum":["es_apikey","es_basic","opensearch_basic","opensearch_sigv4","solr_basic","solr_apikey"],"title":"Auth Kind","type":"string"},"base_url":{"title":"Base Url","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"environment":{"enum":["prod","staging","dev"],"title":"Environment","type":"string"},"health_check":{"$ref":"#/components/schemas/HealthCheckResult"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"target_filter":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Filter"}},"required":["id","name","engine_type","environment","base_url","auth_kind","created_at","health_check"],"title":"ClusterSummary","type":"object"},"ConfidenceShape":{"description":"The top-level shape exposed via ``StudyDetail.confidence``.\n\nEvery sub-field is independently nullable per FR-7 — degraded paths\nsuppress only the sub-fields they affect, never the whole shape (the\norchestrator returns whole-object ``None`` only when the winner trial\nrow itself is missing).","properties":{"ci_95":{"anyOf":[{"$ref":"#/components/schemas/CIShape"},{"type":"null"}]},"convergence":{"anyOf":[{"$ref":"#/components/schemas/ConvergenceShape"},{"type":"null"}]},"headline":{"$ref":"#/components/schemas/HeadlineShape"},"late_trial_stddev":{"anyOf":[{"$ref":"#/components/schemas/LateTrialStddevShape"},{"type":"null"}]},"per_query_outcomes":{"anyOf":[{"$ref":"#/components/schemas/PerQueryOutcomesShape"},{"type":"null"}]},"runner_up_gap":{"anyOf":[{"$ref":"#/components/schemas/RunnerUpGapShape"},{"type":"null"}]}},"required":["headline","ci_95","runner_up_gap","late_trial_stddev","convergence","per_query_outcomes"],"title":"ConfidenceShape","type":"object"},"ConfigRepoDetail":{"description":"``GET /api/v1/config-repos/{id}`` response + ``POST`` 201 body.","properties":{"auth_ref":{"title":"Auth Ref","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"default_branch":{"title":"Default Branch","type":"string"},"id":{"title":"Id","type":"string"},"last_merged_proposal":{"anyOf":[{"$ref":"#/components/schemas/ProposalSummary"},{"type":"null"}]},"name":{"title":"Name","type":"string"},"pr_base_branch":{"title":"Pr Base Branch","type":"string"},"provider":{"const":"github","title":"Provider","type":"string"},"repo_url":{"title":"Repo Url","type":"string"},"webhook_registration_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Registration Error"},"webhook_secret_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Secret Ref"}},"required":["id","name","provider","repo_url","default_branch","pr_base_branch","auth_ref","webhook_secret_ref","webhook_registration_error","created_at"],"title":"ConfigRepoDetail","type":"object"},"ConfigReposListResponse":{"description":"``GET /api/v1/config-repos`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/ConfigRepoDetail"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"ConfigReposListResponse","type":"object"},"ConnectionTestRequest":{"description":"Body for ``POST /api/v1/clusters/test-connection`` (infra_adapter_solr Story A9).\n\nSame shape as ``CreateClusterRequest`` minus the persisted-only fields\n(``name``, ``environment``, ``notes``, ``target_filter``). ``engine_type``\n+ ``auth_kind`` are typed as ``str`` (not Literal) so a bad value yields\nthe project-standard 400 envelope rather than a raw 422 — same convention\nas ``CreateClusterRequest``.","properties":{"auth_kind":{"maxLength":64,"minLength":1,"title":"Auth Kind","type":"string"},"base_url":{"maxLength":512,"minLength":1,"title":"Base Url","type":"string"},"credentials_ref":{"maxLength":128,"minLength":1,"title":"Credentials Ref","type":"string"},"engine_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Engine Config"},"engine_type":{"maxLength":64,"minLength":1,"title":"Engine Type","type":"string"}},"required":["engine_type","base_url","auth_kind","credentials_ref"],"title":"ConnectionTestRequest","type":"object"},"ConnectionTestResult":{"description":"Response for ``POST /api/v1/clusters/test-connection``.\n\nAlways 200 — reachable vs unreachable surfaces via ``reachable`` +\n``status`` fields. The endpoint is a diagnostic, never a mutation,\nso it never returns 503; invalid engine×auth pairings 400 BEFORE the\nnetwork call. (Cycle-delta F1.)","properties":{"engine_capabilities":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Engine Capabilities"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"reachable":{"title":"Reachable","type":"boolean"},"status":{"enum":["green","yellow","red","unreachable"],"title":"Status","type":"string"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"}},"required":["reachable","status"],"title":"ConnectionTestResult","type":"object"},"ConvergenceShape":{"description":"Where the winner sits in the Optuna trial sequence + the classified regime.","properties":{"best_at_trial":{"title":"Best At Trial","type":"integer"},"regime":{"enum":["early_held","late_rising","noisy"],"title":"Regime","type":"string"},"total_trials":{"title":"Total Trials","type":"integer"}},"required":["best_at_trial","total_trials","regime"],"title":"ConvergenceShape","type":"object"},"ConversationDetail":{"description":"``GET /api/v1/conversations/{id}`` response.","properties":{"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"messages":{"items":{"$ref":"#/components/schemas/MessageWire"},"title":"Messages","type":"array"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"}},"required":["id","title","created_at","messages"],"title":"ConversationDetail","type":"object"},"ConversationSummary":{"description":"``GET /api/v1/conversations`` row + ``POST`` 201 body.\n\n``last_message_preview`` is the most recent user / assistant message's\n``content.text``, truncated at the repo layer to 120 chars (with ``…``\nsuffix when cut). Tool-role rows and assistant rows whose ``content.kind``\nis ``system_notice`` are skipped. ``None`` for brand-new conversations\nwith no qualifying messages — see ``chore_chat_last_message_preview``.\n\n``last_message_at`` is the ``created_at`` of that same row, or ``None``\nfor empty conversations. The list page uses it to render \"when did\nanyone last touch this thread\" instead of the conversation's\n``created_at``.","properties":{"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"last_message_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Last Message At"},"last_message_preview":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Message Preview"},"message_count":{"title":"Message Count","type":"integer"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"}},"required":["id","title","created_at","message_count"],"title":"ConversationSummary","type":"object"},"ConversationsListResponse":{"description":"``GET /api/v1/conversations`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/ConversationSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"ConversationsListResponse","type":"object"},"CreateClusterRequest":{"description":"Request body for ``POST /api/v1/clusters``.\n\nSee module docstring for the deliberate ``str`` vs ``Literal`` split.","properties":{"auth_kind":{"maxLength":64,"minLength":1,"title":"Auth Kind","type":"string"},"base_url":{"maxLength":512,"minLength":1,"title":"Base Url","type":"string"},"credentials_ref":{"maxLength":128,"minLength":1,"title":"Credentials Ref","type":"string"},"engine_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Engine Config"},"engine_type":{"maxLength":64,"minLength":1,"title":"Engine Type","type":"string"},"environment":{"enum":["prod","staging","dev"],"title":"Environment","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[a-z0-9][a-z0-9-]*$","title":"Name","type":"string"},"notes":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Notes"},"target_filter":{"anyOf":[{"maxLength":256,"minLength":1,"type":"string"},{"type":"null"}],"description":"Optional glob pattern (fnmatch.fnmatchcase: *, ?, [seq], [!seq]; no brace expansion). Scopes GET /clusters/{id}/targets to matching index names. Null = no filter.","title":"Target Filter"}},"required":["name","engine_type","environment","base_url","auth_kind","credentials_ref"],"title":"CreateClusterRequest","type":"object"},"CreateConfigRepoRequest":{"description":"Body of ``POST /api/v1/config-repos`` (FR-3).\n\n``provider`` is server-derived from ``repo_url`` (cycle-2 F4 from\nspec review) — NOT in the payload. The validator enforces a strict\nGitHub URL pattern; non-GitHub URLs surface as 400\n``UNSUPPORTED_PROVIDER`` at the router layer.","properties":{"auth_ref":{"maxLength":128,"minLength":1,"pattern":"^[a-zA-Z0-9_-]+$","title":"Auth Ref","type":"string"},"default_branch":{"default":"main","maxLength":128,"minLength":1,"title":"Default Branch","type":"string"},"name":{"maxLength":128,"minLength":1,"pattern":"^[a-z0-9][a-z0-9-]*$","title":"Name","type":"string"},"pr_base_branch":{"default":"main","maxLength":128,"minLength":1,"title":"Pr Base Branch","type":"string"},"repo_url":{"maxLength":512,"minLength":1,"title":"Repo Url","type":"string"},"webhook_secret_ref":{"anyOf":[{"maxLength":128,"pattern":"^[a-zA-Z0-9_-]+$","type":"string"},{"type":"null"}],"title":"Webhook Secret Ref"}},"required":["name","repo_url","auth_ref"],"title":"CreateConfigRepoRequest","type":"object"},"CreateConversationRequest":{"description":"``POST /api/v1/conversations`` body.","properties":{"title":{"anyOf":[{"maxLength":200,"type":"string"},{"type":"null"}],"title":"Title"}},"title":"CreateConversationRequest","type":"object"},"CreateJudgmentListFromUbiRequest":{"description":"Body for ``POST /api/v1/judgments/generate-from-ubi`` (Story 3.2 / FR-3).\n\nMirrors :class:`backend.app.services.agent_judgments_dispatch.UbiJudgmentGenerationRequest`.\nThe ``@model_validator(mode=\"after\")`` enforces the conditional\nrequiredness of ``current_template_id`` + ``rubric`` per the hybrid\nconverter: REQUIRED when ``converter == 'hybrid_ubi_llm'`` (the LLM-\nfill path needs both); FORBIDDEN otherwise (pure UBI never calls\nthe LLM so accepting them silently would mask operator error).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"converter":{"enum":["ctr_threshold","dwell_time","hybrid_ubi_llm"],"title":"Converter","type":"string"},"converter_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Converter Config"},"current_template_id":{"anyOf":[{"maxLength":36,"minLength":36,"type":"string"},{"type":"null"}],"title":"Current Template Id"},"description":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Description"},"llm_fill_threshold":{"anyOf":[{"minimum":1.0,"type":"integer"},{"type":"null"}],"default":20,"title":"Llm Fill Threshold"},"mapping_strategy":{"default":"reject","enum":["reject","first_match","most_recent"],"title":"Mapping Strategy","type":"string"},"min_impressions_threshold":{"anyOf":[{"minimum":1.0,"type":"integer"},{"type":"null"}],"default":100,"title":"Min Impressions Threshold"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"query_set_id":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"},"rubric":{"anyOf":[{"minLength":1,"type":"string"},{"type":"null"}],"title":"Rubric"},"since":{"format":"date-time","title":"Since","type":"string"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"},"until":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Until"}},"required":["name","query_set_id","cluster_id","target","since","converter"],"title":"CreateJudgmentListFromUbiRequest","type":"object"},"CreateJudgmentListGenerateRequest":{"description":"Body for ``POST /api/v1/judgments/generate`` (Story 3.1).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"current_template_id":{"maxLength":36,"minLength":1,"title":"Current Template Id","type":"string"},"description":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Description"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"query_set_id":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"},"rubric":{"minLength":1,"title":"Rubric","type":"string"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"}},"required":["name","query_set_id","cluster_id","target","current_template_id","rubric"],"title":"CreateJudgmentListGenerateRequest","type":"object"},"CreateProposalRequest":{"description":"Body of ``POST /api/v1/proposals`` (manual proposal creation, FR-4 / AC-6).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"config_diff":{"additionalProperties":true,"title":"Config Diff","type":"object"},"metric_delta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metric Delta"},"template_id":{"maxLength":36,"minLength":1,"title":"Template Id","type":"string"}},"required":["cluster_id","template_id","config_diff"],"title":"CreateProposalRequest","type":"object"},"CreateQuerySetRequest":{"description":"``POST /api/v1/query-sets`` body.\n\n``cluster_id`` is required because Phase 1's shipped schema has\n``query_sets.cluster_id NOT NULL``. Spec FR-3 wording (``cluster_id?``)\nis documented drift tracked at\n``docs/00_overview/planned_features/chore_spec_query_set_cluster_id_drift/idea.md``.","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"description":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Description"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"}},"required":["name","cluster_id"],"title":"CreateQuerySetRequest","type":"object"},"CreateQueryTemplateRequest":{"description":"Request body for ``POST /api/v1/query-templates``.","properties":{"body":{"minLength":1,"title":"Body","type":"string"},"declared_params":{"additionalProperties":{"type":"string"},"title":"Declared Params","type":"object"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"}},"required":["name","engine_type","body"],"title":"CreateQueryTemplateRequest","type":"object"},"CreateStudyRequest":{"description":"``POST /api/v1/studies`` body.\n\n``search_space`` is validated post-Pydantic-parse via\n:class:`backend.app.domain.study.search_space.SearchSpace` so\n:exc:`pydantic.ValidationError` produces the spec's 400\n``INVALID_SEARCH_SPACE`` (per Story 3.3 task 2).\n\nfeat_digest_executable_followups Story 4.2 — optional ``parent`` field\nrecords the parent proposal + followup-index lineage when the study\nwas spawned from a digest \"Run this followup\" action (FR-11).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"config":{"$ref":"#/components/schemas/StudyConfigSpec"},"judgment_list_id":{"maxLength":36,"minLength":1,"title":"Judgment List Id","type":"string"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"objective":{"$ref":"#/components/schemas/ObjectiveSpec"},"parent":{"anyOf":[{"$ref":"#/components/schemas/ParentFollowupRef"},{"type":"null"}]},"parent_study_id":{"anyOf":[{"maxLength":36,"minLength":36,"type":"string"},{"type":"null"}],"description":"feat_study_clone_from_previous FR-7 — when the operator clones an existing study via the study-detail Clone button, this carries the source study's id. Server validates existence (404 PARENT_STUDY_NOT_FOUND) and same-cluster (422 PARENT_STUDY_WRONG_CLUSTER) before persisting to studies.parent_study_id. Independent of the proposal-lineage 'parent' field (D-5); both may be set.","title":"Parent Study Id"},"query_set_id":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"},"search_space":{"additionalProperties":true,"title":"Search Space","type":"object"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"},"template_id":{"maxLength":36,"minLength":1,"title":"Template Id","type":"string"}},"required":["name","cluster_id","target","template_id","query_set_id","judgment_list_id","search_space","objective","config"],"title":"CreateStudyRequest","type":"object"},"CurvePoint":{"description":"One point on the best-so-far curve.\n\n``trial_number`` is the trial's ``optuna_trial_number`` (the canonical\n\"trial order within the study\" field — see ``auto_followup.py`` module\ndocstring for why we sort by this rather than ``started_at``).\n``best_so_far`` is the running extremum of ``primary_metric`` over all\nearlier trials, sign-corrected to the study's optimization direction.","properties":{"best_so_far":{"title":"Best So Far","type":"number"},"trial_number":{"title":"Trial Number","type":"integer"}},"required":["trial_number","best_so_far"],"title":"CurvePoint","type":"object"},"DigestResponse":{"description":"Body of ``GET /api/v1/studies/{id}/digest`` (FR-3 / AC-3).\n\nfeat_digest_executable_followups Story 4.1 — ``suggested_followups`` is\nnow a discriminated-union list (NarrowFollowup | WidenFollowup |\nTextFollowup), populated by the digest handler via\n``parse_followup_list(digest.suggested_followups, ...)`` so legacy or\nmalformed JSONB payloads never crash the response.","properties":{"generated_at":{"format":"date-time","title":"Generated At","type":"string"},"generated_by":{"title":"Generated By","type":"string"},"id":{"title":"Id","type":"string"},"narrative":{"title":"Narrative","type":"string"},"parameter_importance":{"additionalProperties":{"type":"number"},"title":"Parameter Importance","type":"object"},"recommended_config":{"additionalProperties":true,"title":"Recommended Config","type":"object"},"study_id":{"title":"Study Id","type":"string"},"suggested_followups":{"items":{"$ref":"#/components/schemas/FollowupItem"},"title":"Suggested Followups","type":"array"}},"required":["id","study_id","narrative","parameter_importance","recommended_config","suggested_followups","generated_by","generated_at"],"title":"DigestResponse","type":"object"},"Document":{"description":"A single document by ID — return shape of ``SearchAdapter.get_document``.\n\nMirrors :class:`ScoredHit` minus ``score`` (browsing doesn't need scoring).\n``source`` is ``None`` when the engine's index has ``_source: false`` mapping.","properties":{"doc_id":{"minLength":1,"title":"Doc Id","type":"string"},"source":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Source"}},"required":["doc_id"],"title":"Document","type":"object"},"DocumentListResponse":{"description":"``GET /api/v1/clusters/{cluster_id}/targets/{target}/documents`` response.\n\n``next_cursor`` opaque-encodes the ES ``hits[-1].sort`` array of the\nlast visible row when ``has_more`` is True (see\n``backend.app.api.v1._documents_cursor``). The ``X-Total-Count`` header\non the response carries the engine's ``hits.total.value``.","properties":{"data":{"items":{"$ref":"#/components/schemas/DocumentSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"DocumentListResponse","type":"object"},"DocumentSummary":{"description":"One row in the documents list (per FR-3 / FR-8).\n\n``source`` is the *truncated* preview emitted by\n``backend.app.services.documents.truncate_source_for_list``. The detail\nendpoint returns the untruncated ``Document.source``.","properties":{"doc_id":{"minLength":1,"title":"Doc Id","type":"string"},"source":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Source"}},"required":["doc_id","source"],"title":"DocumentSummary","type":"object"},"FieldSpec":{"description":"One field returned by ``get_schema``.","properties":{"analyzer":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Analyzer"},"doc_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Doc Count"},"name":{"title":"Name","type":"string"},"type":{"title":"Type","type":"string"}},"required":["name","type"],"title":"FieldSpec","type":"object"},"FloatParam":{"additionalProperties":false,"description":"Continuous float parameter.\n\n``log=True`` enables log-uniform sampling\n(Optuna's ``suggest_float(..., log=True)``); requires ``low > 0``.","properties":{"high":{"title":"High","type":"number"},"log":{"default":false,"title":"Log","type":"boolean"},"low":{"title":"Low","type":"number"},"type":{"const":"float","title":"Type","type":"string"}},"required":["type","low","high"],"title":"FloatParam","type":"object"},"FollowupItem":{"discriminator":{"mapping":{"narrow":"#/components/schemas/NarrowFollowup","swap_template":"#/components/schemas/SwapTemplateFollowup","text":"#/components/schemas/TextFollowup","widen":"#/components/schemas/WidenFollowup"},"propertyName":"kind"},"oneOf":[{"$ref":"#/components/schemas/NarrowFollowup"},{"$ref":"#/components/schemas/WidenFollowup"},{"$ref":"#/components/schemas/TextFollowup"},{"$ref":"#/components/schemas/SwapTemplateFollowup"}]},"GenerateJudgmentsResponse":{"description":"Response of ``POST /api/v1/judgments/generate``.\n\nPer GPT-5.5 cycle 1 F5 — the endpoint registers a typed\n``response_model`` so OpenAPI introspection + contract tests can verify\nthe wire shape.","properties":{"judgment_list_id":{"title":"Judgment List Id","type":"string"},"status":{"const":"generating","title":"Status","type":"string"}},"required":["judgment_list_id","status"],"title":"GenerateJudgmentsResponse","type":"object"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"HeadlineShape":{"description":"Top-line metric value + N(queries) used in the CI.\n\n``metric`` uses ``str`` (not ``ObjectiveMetric``) to avoid a circular\nimport: ``schemas.py`` imports ``ConfidenceShape`` from here, so this\nmodule cannot import back from ``schemas.py``. The upstream value is\nalready validated by the existing ``ObjectiveMetric`` Literal at the\ncreate-study endpoint (``schemas.py:214``).","properties":{"k":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"K"},"metric":{"title":"Metric","type":"string"},"n_queries":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"N Queries"},"value":{"title":"Value","type":"number"}},"required":["metric","value","k","n_queries"],"title":"HeadlineShape","type":"object"},"HealthCheckResult":{"description":"Wire shape of the per-cluster health probe (mirrors ``HealthStatus``).","properties":{"checked_at":{"title":"Checked At","type":"string"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"status":{"enum":["green","yellow","red","unreachable"],"title":"Status","type":"string"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"}},"required":["status","checked_at"],"title":"HealthCheckResult","type":"object"},"HealthResponse":{"description":"The /healthz response body. Same shape for HTTP 200 and 503.","properties":{"openai_capabilities":{"$ref":"#/components/schemas/OpenAICapabilities"},"openai_endpoint":{"description":"Configured OPENAI_BASE_URL","title":"Openai Endpoint","type":"string"},"status":{"enum":["ok","degraded"],"title":"Status","type":"string"},"subsystems":{"$ref":"#/components/schemas/Subsystems"},"uptime_seconds":{"description":"Seconds since the API process started","title":"Uptime Seconds","type":"integer"},"version":{"description":"Application version (relyloop_git_sha)","title":"Version","type":"string"}},"required":["status","subsystems","openai_endpoint","openai_capabilities","version","uptime_seconds"],"title":"HealthResponse","type":"object"},"ImportJudgmentItem":{"description":"One row in :class:`ImportJudgmentListRequest`.","properties":{"doc_id":{"maxLength":512,"minLength":1,"title":"Doc Id","type":"string"},"notes":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Notes"},"query_id":{"maxLength":36,"minLength":1,"title":"Query Id","type":"string"},"rating":{"enum":[0,1,2,3],"title":"Rating","type":"integer"}},"required":["query_id","doc_id","rating"],"title":"ImportJudgmentItem","type":"object"},"ImportJudgmentListRequest":{"description":"Body for ``POST /api/v1/judgment-lists/import`` (Story 3.2).","properties":{"cluster_id":{"maxLength":36,"minLength":1,"title":"Cluster Id","type":"string"},"description":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Description"},"judgments":{"items":{"$ref":"#/components/schemas/ImportJudgmentItem"},"maxItems":100000,"minItems":1,"title":"Judgments","type":"array"},"name":{"maxLength":256,"minLength":1,"title":"Name","type":"string"},"query_set_id":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"},"rubric":{"minLength":1,"title":"Rubric","type":"string"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"}},"required":["name","query_set_id","cluster_id","target","rubric","judgments"],"title":"ImportJudgmentListRequest","type":"object"},"IntParam":{"additionalProperties":false,"description":"Integer parameter inclusive of both bounds.","properties":{"high":{"title":"High","type":"integer"},"low":{"title":"Low","type":"integer"},"type":{"const":"int","title":"Type","type":"string"}},"required":["type","low","high"],"title":"IntParam","type":"object"},"JudgmentListDetail":{"description":"``GET /api/v1/judgment-lists/{id}`` response.\n\nNote: ``generation_params`` is populated for UBI lists (feat_ubi_judgments\nStory 1.1's JSONB column) and NULL for LLM lists. The Story 4.3 UI\n(```` + ````) reads the\npayload to discriminate UBI/hybrid lists and to reconstruct the\noriginal request for the ambiguous-skip \"Re-run with most_recent\"\naffordance.","properties":{"calibration":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Calibration"},"cluster_id":{"title":"Cluster Id","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"current_template_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Current Template Id"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"failed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failed Reason"},"generation_params":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Generation Params"},"id":{"title":"Id","type":"string"},"judgment_count":{"title":"Judgment Count","type":"integer"},"name":{"title":"Name","type":"string"},"query_set_id":{"title":"Query Set Id","type":"string"},"rubric":{"title":"Rubric","type":"string"},"source_breakdown":{"$ref":"#/components/schemas/_SourceBreakdown"},"status":{"enum":["generating","complete","failed"],"title":"Status","type":"string"},"target":{"title":"Target","type":"string"}},"required":["id","name","description","query_set_id","cluster_id","target","current_template_id","rubric","status","failed_reason","judgment_count","source_breakdown","calibration","generation_params","created_at"],"title":"JudgmentListDetail","type":"object"},"JudgmentListJudgmentsResponse":{"description":"``GET /api/v1/judgment-lists/{id}/judgments`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/JudgmentRow"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"JudgmentListJudgmentsResponse","type":"object"},"JudgmentListListResponse":{"description":"``GET /api/v1/judgment-lists`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/JudgmentListSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"JudgmentListListResponse","type":"object"},"JudgmentListRef":{"description":"One entry in the ``QUERY_HAS_JUDGMENTS`` 409 envelope.\n\nLives in ``detail.judgment_lists``. Maps from the repo-layer\n:class:`backend.app.db.repo.judgment.JudgmentListRefRow` at the\nrouter boundary.","properties":{"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"}},"required":["id","name"],"title":"JudgmentListRef","type":"object"},"JudgmentListSummary":{"description":"List-view row on ``GET /api/v1/judgment-lists``.","properties":{"cluster_id":{"title":"Cluster Id","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"query_set_id":{"title":"Query Set Id","type":"string"},"status":{"enum":["generating","complete","failed"],"title":"Status","type":"string"},"target":{"title":"Target","type":"string"}},"required":["id","name","description","query_set_id","cluster_id","target","status","created_at"],"title":"JudgmentListSummary","type":"object"},"JudgmentRow":{"description":"``GET /api/v1/judgment-lists/{id}/judgments`` row + PATCH response.","properties":{"confidence":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Confidence"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"doc_id":{"title":"Doc Id","type":"string"},"id":{"title":"Id","type":"string"},"judgment_list_id":{"title":"Judgment List Id","type":"string"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes"},"query_id":{"title":"Query Id","type":"string"},"rater_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Rater Ref"},"rating":{"enum":[0,1,2,3],"title":"Rating","type":"integer"},"source":{"enum":["llm","human","click"],"title":"Source","type":"string"}},"required":["id","judgment_list_id","query_id","doc_id","rating","source","rater_ref","confidence","notes","created_at"],"title":"JudgmentRow","type":"object"},"LateTrialStddevShape":{"description":"Sample stddev of ``primary_metric`` over the late-trial window.","properties":{"min_window_required":{"title":"Min Window Required","type":"integer"},"value":{"title":"Value","type":"number"},"window_size":{"title":"Window Size","type":"integer"}},"required":["value","window_size","min_window_required"],"title":"LateTrialStddevShape","type":"object"},"MessageWire":{"description":"One row of ``GET /api/v1/conversations/{id}.messages``.","properties":{"content":{"additionalProperties":true,"title":"Content","type":"object"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"role":{"enum":["user","assistant","tool"],"title":"Role","type":"string"},"tool_calls":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Tool Calls"}},"required":["id","role","content","created_at"],"title":"MessageWire","type":"object"},"NarrowFollowup":{"additionalProperties":false,"description":"A 'narrow' followup — re-run with a tighter range than the parent.","properties":{"kind":{"const":"narrow","title":"Kind","type":"string"},"rationale":{"title":"Rationale","type":"string"},"search_space":{"$ref":"#/components/schemas/SearchSpace"}},"required":["kind","rationale","search_space"],"title":"NarrowFollowup","type":"object"},"ObjectiveSpec":{"description":"Wire shape of ``studies.objective`` (write-side validated at create).\n\n``k`` is required for ``ndcg`` / ``precision`` / ``recall`` (per\nstandard IR-evaluation conventions: those metrics are computed at a\ncutoff rank). ``map`` accepts ``k`` optionally; ``mrr`` / ``err`` ignore\nit. The model_validator enforces this so a malformed objective\nsurfaces as 400 ``INVALID_SEARCH_SPACE`` / 422 ``VALIDATION_ERROR``\nat study-create time rather than failing later inside ``run_trial``\nwhen the worker computes the metric.","properties":{"direction":{"default":"maximize","enum":["maximize","minimize"],"title":"Direction","type":"string"},"k":{"anyOf":[{"enum":[1,3,5,10,20,50,100],"type":"integer"},{"type":"null"}],"title":"K"},"metric":{"enum":["ndcg","map","precision","recall","mrr"],"title":"Metric","type":"string"}},"required":["metric"],"title":"ObjectiveSpec","type":"object"},"OpenAICapabilities":{"description":"Cached results of the OpenAI capability check (Story 3.3 populates Redis).\n\nStep 1 (``models_endpoint``) is reported first because it gates the rest:\nwhen it fails, the other three are reported as ``\"untested\"``. The\n``models_endpoint_status_code`` field is required-but-nullable\n(per ``bug_openai_capability_check_incapable_on_valid_key`` spec §19 D-3/D-8)\n— always present in the JSON, ``null`` when not applicable. This lets\noperators distinguish ``401 -> bad key``, ``429 -> quota``,\n``5xx -> upstream outage``, ``null -> network unreachable / cache miss``.","properties":{"chat":{"description":"Chat completion probe result","enum":["ok","fail","untested"],"title":"Chat","type":"string"},"function_calling":{"description":"Function-calling probe result (tool_choice=required)","enum":["ok","fail","untested"],"title":"Function Calling","type":"string"},"models_endpoint":{"description":"GET /models probe outcome. 'ok' / 'fail' are projected from CapabilityResult.models_endpoint; 'untested' is the cache-miss default, matching the existing chat / function_calling / structured_output cache-miss handling.","enum":["ok","fail","untested"],"title":"Models Endpoint","type":"string"},"models_endpoint_status_code":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"HTTP status code from the GET /models probe when it HTTP-failed (>= 400). null for the success path, network-class failure (timeout / DNS / connection-refused), or cache miss. Required-but-nullable: the JSON key is always present with explicit null when no value, never omitted.","title":"Models Endpoint Status Code"},"structured_output":{"description":"JSON-schema response_format probe result","enum":["ok","fail","untested"],"title":"Structured Output","type":"string"}},"required":["models_endpoint","models_endpoint_status_code","chat","function_calling","structured_output"],"title":"OpenAICapabilities","type":"object"},"OpenPrResponse":{"description":"Body of ``POST /api/v1/proposals/{id}/open_pr`` (FR-1).\n\nReturned with HTTP 202 on successful enqueue. Status is always\n``'pending'`` at enqueue time; the worker flips it to ``'pr_opened'``\nafter the PR is open.","properties":{"message":{"title":"Message","type":"string"},"proposal_id":{"title":"Proposal Id","type":"string"},"status":{"const":"pending","title":"Status","type":"string"}},"required":["proposal_id","status","message"],"title":"OpenPrResponse","type":"object"},"OverrideJudgmentRequest":{"description":"Body for ``PATCH /api/v1/judgment-lists/{id}/judgments/{judgment_id}``.\n\n``rating`` is INTENTIONALLY unbounded at the Pydantic layer — spec §8.5\nrequires out-of-range failures to surface as 400 ``INVALID_RATING`` (not\nPydantic's default 422 ``VALIDATION_ERROR``). The handler validates the\nvalue manually and raises the domain code (per GPT-5.5 cycle 1 F4).","properties":{"notes":{"anyOf":[{"maxLength":2000,"type":"string"},{"type":"null"}],"title":"Notes"},"rating":{"title":"Rating","type":"integer"}},"required":["rating"],"title":"OverrideJudgmentRequest","type":"object"},"ParentFollowupRef":{"description":"Optional lineage payload on ``POST /api/v1/studies``.\n\nfeat_digest_executable_followups FR-11 — when the operator clicks\n\"Run this followup\" on a proposal's digest card, the create-study\npayload carries the parent proposal's id + the 0-based index into\nthe digest's ``suggested_followups`` array so the spawned study\nremembers where it came from.\n\n``proposal_id`` is a UUIDv7 (36-char hex). The exact-length bound\nforces malformed strings to surface as 422 ``VALIDATION_ERROR``\nrather than reach the DB FK check and emerge as a 404\n``PROPOSAL_NOT_FOUND``.","properties":{"followup_index":{"minimum":0.0,"title":"Followup Index","type":"integer"},"proposal_id":{"maxLength":36,"minLength":36,"title":"Proposal Id","type":"string"}},"required":["proposal_id","followup_index"],"title":"ParentFollowupRef","type":"object"},"PerQueryOutcomesShape":{"description":"Per-query outcome counts + the top-5 named regressors and improvers.","properties":{"comparison_against":{"enum":["runner_up","baseline"],"title":"Comparison Against","type":"string"},"improved":{"title":"Improved","type":"integer"},"regressed":{"title":"Regressed","type":"integer"},"top_improvers":{"default":[],"items":{"$ref":"#/components/schemas/RegressorRowShape"},"title":"Top Improvers","type":"array"},"top_regressors":{"items":{"$ref":"#/components/schemas/RegressorRowShape"},"title":"Top Regressors","type":"array"},"unchanged":{"title":"Unchanged","type":"integer"}},"required":["improved","unchanged","regressed","comparison_against","top_regressors"],"title":"PerQueryOutcomesShape","type":"object"},"ProposalDetail":{"description":"Body of the proposal detail endpoints.\n\nUsed by ``GET /api/v1/proposals/{id}``, ``POST /api/v1/proposals``,\nand ``POST /api/v1/proposals/{id}/reject``.","properties":{"cluster":{"$ref":"#/components/schemas/_ClusterEmbed"},"config_diff":{"additionalProperties":true,"title":"Config Diff","type":"object"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"digest":{"anyOf":[{"$ref":"#/components/schemas/_DigestEmbed"},{"type":"null"}]},"id":{"title":"Id","type":"string"},"is_currently_live":{"default":false,"title":"Is Currently Live","type":"boolean"},"metric_delta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metric Delta"},"pr_merged_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Pr Merged At"},"pr_open_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pr Open Error"},"pr_state":{"anyOf":[{"enum":["open","closed","merged"],"type":"string"},{"type":"null"}],"title":"Pr State"},"pr_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pr Url"},"rejected_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Rejected Reason"},"status":{"enum":["pending","pr_opened","pr_merged","rejected","superseded"],"title":"Status","type":"string"},"study_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Study Id"},"study_summary":{"anyOf":[{"$ref":"#/components/schemas/_StudySummary"},{"type":"null"}]},"study_trial_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Study Trial Id"},"template":{"$ref":"#/components/schemas/_TemplateEmbed"}},"required":["id","study_id","study_summary","study_trial_id","cluster","template","config_diff","metric_delta","status","pr_url","pr_state","pr_merged_at","pr_open_error","rejected_reason","digest","created_at"],"title":"ProposalDetail","type":"object"},"ProposalSummary":{"description":"Row in the ``GET /api/v1/proposals`` list response.","properties":{"cluster":{"$ref":"#/components/schemas/_ClusterEmbed"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"is_currently_live":{"default":false,"title":"Is Currently Live","type":"boolean"},"metric_delta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metric Delta"},"pr_state":{"anyOf":[{"enum":["open","closed","merged"],"type":"string"},{"type":"null"}],"title":"Pr State"},"pr_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pr Url"},"status":{"enum":["pending","pr_opened","pr_merged","rejected","superseded"],"title":"Status","type":"string"},"study_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Study Id"},"template":{"$ref":"#/components/schemas/_TemplateEmbed"}},"required":["id","study_id","cluster","template","status","pr_state","pr_url","metric_delta","created_at"],"title":"ProposalSummary","type":"object"},"ProposalsListResponse":{"description":"Body of ``GET /api/v1/proposals``.","properties":{"data":{"items":{"$ref":"#/components/schemas/ProposalSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"ProposalsListResponse","type":"object"},"QueryHasJudgmentsDetail":{"description":"The ``detail`` object of a 409 ``QUERY_HAS_JUDGMENTS`` response.\n\nExtends the canonical ``{error_code, message, retryable}`` envelope\nwith two structured fields the frontend consumes directly\n(``judgment_lists`` + ``overflow_count``). Wired into the FastAPI\nroute's ``responses={409: {\"model\": QueryHasJudgmentsEnvelope}}`` so\nthe OpenAPI schema documents the contract.","properties":{"error_code":{"const":"QUERY_HAS_JUDGMENTS","title":"Error Code","type":"string"},"judgment_lists":{"items":{"$ref":"#/components/schemas/JudgmentListRef"},"title":"Judgment Lists","type":"array"},"message":{"title":"Message","type":"string"},"overflow_count":{"title":"Overflow Count","type":"integer"},"retryable":{"const":false,"title":"Retryable","type":"boolean"}},"required":["error_code","message","retryable","judgment_lists","overflow_count"],"title":"QueryHasJudgmentsDetail","type":"object"},"QueryHasJudgmentsEnvelope":{"description":"Top-level 409 wrapper (FastAPI nests under ``detail`` for HTTPException).","properties":{"detail":{"$ref":"#/components/schemas/QueryHasJudgmentsDetail"}},"required":["detail"],"title":"QueryHasJudgmentsEnvelope","type":"object"},"QueryListResponse":{"description":"``GET /api/v1/query-sets/{set_id}/queries`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/QueryRow"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"QueryListResponse","type":"object"},"QueryRow":{"description":"Wire row returned by the per-query GET + PATCH endpoints.\n\nUsed by both ``GET /api/v1/query-sets/{set_id}/queries`` and\n``PATCH /api/v1/query-sets/{set_id}/queries/{query_id}``.\n``judgment_count`` is a derived field — single batched GROUP BY in the\nrouter via :func:`backend.app.db.repo.judgment.count_judgments_per_query`.","properties":{"id":{"title":"Id","type":"string"},"judgment_count":{"title":"Judgment Count","type":"integer"},"query_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Query Metadata"},"query_text":{"title":"Query Text","type":"string"},"reference_answer":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reference Answer"}},"required":["id","query_text","reference_answer","query_metadata","judgment_count"],"title":"QueryRow","type":"object"},"QuerySetDetail":{"description":"``GET /api/v1/query-sets/{id}`` response.","properties":{"cluster_id":{"title":"Cluster Id","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"query_count":{"title":"Query Count","type":"integer"}},"required":["id","name","description","cluster_id","query_count","created_at"],"title":"QuerySetDetail","type":"object"},"QuerySetListResponse":{"description":"``GET /api/v1/query-sets`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/QuerySetSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"QuerySetListResponse","type":"object"},"QuerySetSummary":{"description":"List-view shape.\n\n``query_count`` is the number of queries in the set. It is resolved\nvia a single batched ``GROUP BY query_set_id`` aggregate per page\n(``repo.count_queries_for_sets``), NOT a per-row count — so the\nlist endpoint stays at a fixed 2 queries (the page + the count\naggregate) regardless of page size. This is the same no-N+1 pattern\n``feat_studies_convergence_visibility`` (PR #421) used for the\nstudies-list ``trial_count`` field.","properties":{"cluster_id":{"title":"Cluster Id","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"query_count":{"title":"Query Count","type":"integer"}},"required":["id","name","cluster_id","query_count","created_at"],"title":"QuerySetSummary","type":"object"},"QueryTemplateDetail":{"description":"``GET /api/v1/query-templates/{id}`` response.","properties":{"body":{"title":"Body","type":"string"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"declared_params":{"additionalProperties":{"type":"string"},"title":"Declared Params","type":"object"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"version":{"title":"Version","type":"integer"}},"required":["id","name","engine_type","body","declared_params","version","parent_id","created_at"],"title":"QueryTemplateDetail","type":"object"},"QueryTemplateListResponse":{"description":"``GET /api/v1/query-templates`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/QueryTemplateSummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"QueryTemplateListResponse","type":"object"},"QueryTemplateSummary":{"description":"List-view shape; drops ``body`` + the full ``declared_params`` dict.\n\nSurfaces ``param_count`` (= ``len(declared_params)``) so the\ntemplates list can show each template's tuning surface at a glance.\n``param_count`` is free to compute — ``declared_params`` is a JSONB\ncolumn already loaded on the row (not a child relationship), so the\ncount is ``len(row.declared_params)`` with no extra query and no\nN+1 risk. The full dict remains on ``QueryTemplateDetail``.","properties":{"created_at":{"format":"date-time","title":"Created At","type":"string"},"engine_type":{"enum":["elasticsearch","opensearch","solr"],"title":"Engine Type","type":"string"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"param_count":{"title":"Param Count","type":"integer"},"version":{"title":"Version","type":"integer"}},"required":["id","name","engine_type","version","param_count","created_at"],"title":"QueryTemplateSummary","type":"object"},"RecentChainSummary":{"description":"One row in the ``GET /api/v1/studies/chains/recent`` response.\n\nPer spec §8.1 (feat_overnight_studies_summary_card). Per-chain\nrollup feeding the \"Ran while you were away\" card on ``/studies``\n— anchor identity + chain length + the best link's metric + the\nchain's cumulative lift + the derived stop reason + the\nsurfaceable proposal id for the best link. Read-only; no state\ntransitions, no audit events.","properties":{"anchor_name":{"title":"Anchor Name","type":"string"},"anchor_study_id":{"title":"Anchor Study Id","type":"string"},"best_link_proposal_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Best Link Proposal Id"},"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"chain_length":{"title":"Chain Length","type":"integer"},"cumulative_lift":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cumulative Lift"},"direction":{"enum":["maximize","minimize"],"title":"Direction","type":"string"},"objective_metric":{"title":"Objective Metric","type":"string"},"stop_reason":{"enum":["depth_exhausted","no_lift","budget","parent_failed","cancelled","in_flight"],"title":"Stop Reason","type":"string"},"tail_completed_at":{"format":"date-time","title":"Tail Completed At","type":"string"}},"required":["anchor_study_id","anchor_name","chain_length","best_metric","objective_metric","cumulative_lift","direction","stop_reason","best_link_proposal_id","tail_completed_at"],"title":"RecentChainSummary","type":"object"},"RecentChainsResponse":{"description":"``GET /api/v1/studies/chains/recent`` response shape.\n\nInert pagination: this endpoint emits ``next_cursor=null`` and\n``has_more=false`` always (OQ-2 resolved — limit-cap only). The\nfields stay on the wire for consistency with the rest of the\nstudies surface, so a future MVP3 keyset-pagination story can\npopulate them without breaking clients (idea filed in this PR).","properties":{"data":{"items":{"$ref":"#/components/schemas/RecentChainSummary"},"title":"Data","type":"array"},"has_more":{"default":false,"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data"],"title":"RecentChainsResponse","type":"object"},"RegressorRowShape":{"description":"One row in the named-regressors or named-improvers table.\n\nUsed for BOTH the ``top_regressors`` and ``top_improvers`` lists.\nThe wire shape is identical — ``delta = winner_score - comparison_score``\nis negative on the regressor list, positive on the improver list. The\nclass name is historical (regressors shipped first); reusing the same\ntype keeps the schema and the per-row renderer compact.","properties":{"comparison_score":{"title":"Comparison Score","type":"number"},"delta":{"title":"Delta","type":"number"},"query_id":{"title":"Query Id","type":"string"},"query_text":{"title":"Query Text","type":"string"},"winner_score":{"title":"Winner Score","type":"number"}},"required":["query_id","query_text","winner_score","comparison_score","delta"],"title":"RegressorRowShape","type":"object"},"RejectProposalRequest":{"description":"Body of ``POST /api/v1/proposals/{id}/reject`` (FR-4 / AC-5).","properties":{"reason":{"anyOf":[{"maxLength":500,"type":"string"},{"type":"null"}],"title":"Reason"}},"title":"RejectProposalRequest","type":"object"},"ReseedStatusResponse":{"additionalProperties":false,"description":"Polling-endpoint response for ``GET /api/v1/_test/demo/reseed/status``.\n\nPer ``bug_demo_reseed_fake_metric_regression`` D-2. Lives in Redis as a\nsingle JSON blob keyed by :data:`DEMO_RESEED_STATUS_KEY` so the\nhandler reads it in one round-trip.","properties":{"current_step":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Current Step"},"failed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failed Reason"},"finished_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Finished At"},"scenarios_completed":{"default":0,"title":"Scenarios Completed","type":"integer"},"scenarios_skipped":{"items":{"type":"string"},"title":"Scenarios Skipped","type":"array"},"scenarios_total":{"default":0,"title":"Scenarios Total","type":"integer"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"status":{"enum":["idle","running","complete","failed"],"title":"Status","type":"string"},"steps":{"items":{"type":"string"},"title":"Steps","type":"array"},"summary":{"anyOf":[{"$ref":"#/components/schemas/ReseedSummary"},{"type":"null"}]}},"required":["status"],"title":"ReseedStatusResponse","type":"object"},"ReseedSummary":{"additionalProperties":false,"description":"Returned by :func:`reseed_demo_state` on success.\n\nPer spec §9 Required invariants, every counter is exactly 4 on the\nhappy path; ``duration_ms`` is wall-clock from orchestration start\nto the rename commit.","properties":{"clusters_created":{"title":"Clusters Created","type":"integer"},"duration_ms":{"title":"Duration Ms","type":"integer"},"proposals_created":{"title":"Proposals Created","type":"integer"},"query_sets_created":{"title":"Query Sets Created","type":"integer"},"studies_completed":{"title":"Studies Completed","type":"integer"}},"required":["clusters_created","query_sets_created","studies_completed","proposals_created","duration_ms"],"title":"ReseedSummary","type":"object"},"RunQueryHit":{"description":"One hit in the ``run_query`` response.","properties":{"doc_id":{"title":"Doc Id","type":"string"},"score":{"title":"Score","type":"number"},"source":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Source"}},"required":["doc_id","score"],"title":"RunQueryHit","type":"object"},"RunQueryRequest":{"description":"``POST /api/v1/clusters/{id}/run_query`` body.","properties":{"query_dsl":{"additionalProperties":true,"title":"Query Dsl","type":"object"},"target":{"maxLength":256,"minLength":1,"title":"Target","type":"string"},"top_k":{"default":10,"maximum":1000.0,"minimum":1.0,"title":"Top K","type":"integer"}},"required":["target","query_dsl"],"title":"RunQueryRequest","type":"object"},"RunQueryResponse":{"description":"``POST /api/v1/clusters/{id}/run_query`` response.","properties":{"hits":{"items":{"$ref":"#/components/schemas/RunQueryHit"},"title":"Hits","type":"array"}},"required":["hits"],"title":"RunQueryResponse","type":"object"},"RunnerUpGapShape":{"description":"Runner-up trial's metric vs the winner.\n\nThe whole shape is suppressed to ``None`` when there are <2 complete\ntrials (FR-2 + FR-7); ``classification`` is non-null whenever this shape\nis present.","properties":{"classification":{"enum":["robust_plateau","sharp_peak"],"title":"Classification","type":"string"},"runner_up_metric":{"title":"Runner Up Metric","type":"number"},"top10_within":{"title":"Top10 Within","type":"number"},"value":{"title":"Value","type":"number"}},"required":["value","classification","top10_within","runner_up_metric"],"title":"RunnerUpGapShape","type":"object"},"Schema":{"description":"An index / collection's field schema.","properties":{"fields":{"items":{"$ref":"#/components/schemas/FieldSpec"},"title":"Fields","type":"array"},"name":{"title":"Name","type":"string"}},"required":["name","fields"],"title":"Schema","type":"object"},"SearchSpace":{"additionalProperties":false,"description":"Pydantic model for the ``studies.search_space`` JSONB column.\n\nWire format::\n\n {\n \"params\": {\n \"boost_title\": {\"type\": \"float\", \"low\": 0.1, \"high\": 10.0, \"log\": true},\n \"min_should_match\": {\"type\": \"int\", \"low\": 1, \"high\": 5},\n \"operator\": {\"type\": \"categorical\", \"choices\": [\"and\", \"or\"]},\n }\n }","properties":{"params":{"additionalProperties":{"discriminator":{"mapping":{"categorical":"#/components/schemas/CategoricalParam","float":"#/components/schemas/FloatParam","int":"#/components/schemas/IntParam"},"propertyName":"type"},"oneOf":[{"$ref":"#/components/schemas/FloatParam"},{"$ref":"#/components/schemas/IntParam"},{"$ref":"#/components/schemas/CategoricalParam"}]},"minProperties":1,"title":"Params","type":"object"}},"required":["params"],"title":"SearchSpace","type":"object"},"SeedAutoFollowupChainRequest":{"additionalProperties":false,"description":"Payload for ``POST /api/v1/_test/auto-followup/seed-chain``.\n\nSeeds ``depth + 1`` linked studies (root → … → leaf) so E2E tests can\ncover the chain-panel parent-link / children-table / cascade-radio paths\nthat the public ``POST /api/v1/studies`` endpoint can't drive\n(``parent_study_id`` is set only by the auto-followup worker).\n\nCloses ``chore_auto_followup_e2e_chain_seed_helper`` (idea #2).","properties":{"cluster_id":{"minLength":1,"title":"Cluster Id","type":"string"},"depth":{"description":"Number of chain hops to seed. depth=1 → root + leaf (2 nodes). depth=2 → root + 1 middle + leaf (3 nodes).","maximum":5.0,"minimum":1.0,"title":"Depth","type":"integer"},"in_flight_leaf":{"default":true,"description":"When True (default), the deepest node is left at status='queued'. When False, it's driven to 'completed' too. Default True matches the primary E2E use case: cascade-radio coverage where the middle node needs an in-flight child.","title":"In Flight Leaf","type":"boolean"},"in_flight_middle":{"default":true,"description":"When True (default), the immediate parent of the leaf is left at status='queued' so the Cancel button is enabled (canCancel = running || queued per study-action-bar.tsx:46). Required for the cancel-modal cascade-radio test. When False, all intermediates are completed (more realistic chain state but cancel modal won't open on the middle).","title":"In Flight Middle","type":"boolean"},"judgment_list_id":{"minLength":1,"title":"Judgment List Id","type":"string"},"query_set_id":{"minLength":1,"title":"Query Set Id","type":"string"},"template_id":{"minLength":1,"title":"Template Id","type":"string"}},"required":["cluster_id","query_set_id","template_id","judgment_list_id","depth"],"title":"SeedAutoFollowupChainRequest","type":"object"},"SeedAutoFollowupChainResponse":{"description":"IDs of every node in the seeded chain, in parent→child order.","properties":{"leaf_id":{"title":"Leaf Id","type":"string"},"middle_ids":{"items":{"type":"string"},"title":"Middle Ids","type":"array"},"root_id":{"title":"Root Id","type":"string"}},"required":["root_id","middle_ids","leaf_id"],"title":"SeedAutoFollowupChainResponse","type":"object"},"SeedCompletedStudyRequest":{"additionalProperties":false,"description":"Payload for ``POST /api/v1/_test/studies/seed-completed``.\n\nAll four FK fields are required; the caller is responsible for\nseeding the parent rows first (typically via the public\n``seedFullChain`` E2E helper).","properties":{"cluster_id":{"minLength":1,"title":"Cluster Id","type":"string"},"extra_trial_metrics":{"anyOf":[{"items":{"type":"number"},"type":"array"},{"type":"null"}],"description":"Optional list of additional complete-trial `primary_metric` values (numbered from 2 upward) seeded on top of the default winner (0.487) + runner-up (0.412). Used to push the study past the convergence classifier's usable-trial floor (5) so the `` renders a real verdict + curve instead of the too_few_trials null state (feat_study_convergence_indicator). Every value MUST be < 0.487 so the winner / best_metric / proposal / digest stay anchored to the unchanged 0.412 -> 0.487 story. Omit for the default 2-trial shape.","title":"Extra Trial Metrics"},"judgment_list_id":{"minLength":1,"title":"Judgment List Id","type":"string"},"query_set_id":{"minLength":1,"title":"Query Set Id","type":"string"},"runner_up_per_query":{"anyOf":[{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object"},{"type":"null"}],"description":"Optional per-query metrics for the runner-up trial; pairs with `winner_per_query`.","title":"Runner Up Per Query"},"suggested_followups":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"description":"feat_digest_executable_followups Story 6.1 — optional structured FollowupItem list (`[{kind, rationale, search_space}]`) to seed on the digest. When omitted, the seeder writes two default text-kind items. The E2E Run-followup spec passes a `narrow` item so it can drive the per-card Run button + modal prefill flow.","title":"Suggested Followups"},"template_id":{"minLength":1,"title":"Template Id","type":"string"},"winner_per_query":{"anyOf":[{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object"},{"type":"null"}],"description":"Optional per-query metrics dict to populate on the winner trial. Shape: `{query_id: {metric_token: float}}` where metric_token matches what `scoring.score()` emits (e.g. `ndcg@10`). Set alongside `runner_up_per_query` to drive the ConfidencePanel happy path on `/studies/[id]`. When omitted, the seeded trials have `per_query_metrics IS NULL` (the pre-feat_pr_metric_confidence shape).","title":"Winner Per Query"},"with_pending_proposal":{"default":true,"description":"When true (default), also insert a `status='pending'` proposal linked to the study so the digest panel's Open PR button renders enabled. Set false to test the AC-11 aria-disabled-button + tooltip path.","title":"With Pending Proposal","type":"boolean"}},"required":["cluster_id","query_set_id","template_id","judgment_list_id"],"title":"SeedCompletedStudyRequest","type":"object"},"SeedCompletedStudyResponse":{"description":"IDs of the inserted rows; mirrors :class:`SeededStudyTriple`.","properties":{"digest_id":{"title":"Digest Id","type":"string"},"proposal_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Proposal Id"},"study_id":{"title":"Study Id","type":"string"}},"required":["study_id","digest_id","proposal_id"],"title":"SeedCompletedStudyResponse","type":"object"},"SendMessageRequest":{"description":"``POST /api/v1/conversations/{id}/messages`` body (Story 3.2).","properties":{"content":{"$ref":"#/components/schemas/SendMessageRequestContent"},"role":{"const":"user","default":"user","title":"Role","type":"string"}},"required":["content"],"title":"SendMessageRequest","type":"object"},"SendMessageRequestContent":{"description":"Sub-shape inside :class:`SendMessageRequest`.","properties":{"text":{"maxLength":20000,"minLength":1,"title":"Text","type":"string"}},"required":["text"],"title":"SendMessageRequestContent","type":"object"},"StudyChainLink":{"description":"One link in the rolled-up overnight-chain summary (feat_overnight_autopilot §8.3).","properties":{"auto_followup_depth_remaining":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Auto Followup Depth Remaining"},"baseline_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Baseline Metric"},"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"completed_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Completed At"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"delta_from_prev":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Delta From Prev"},"direction":{"enum":["maximize","minimize"],"title":"Direction","type":"string"},"failed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failed Reason"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"proposal_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Proposal Id"},"selected_followup_kind":{"anyOf":[{"enum":["narrow_default","narrow","widen","swap_template"],"type":"string"},{"type":"null"}],"title":"Selected Followup Kind"},"status":{"enum":["queued","running","completed","cancelled","failed"],"title":"Status","type":"string"},"template_id":{"title":"Template Id","type":"string"}},"required":["id","name","status","best_metric","baseline_metric","direction","delta_from_prev","proposal_id","auto_followup_depth_remaining","failed_reason","created_at","completed_at","template_id"],"title":"StudyChainLink","type":"object"},"StudyChainResponse":{"description":"``GET /api/v1/studies/{id}/chain`` response (feat_overnight_autopilot §8.3).","properties":{"anchor_study_id":{"title":"Anchor Study Id","type":"string"},"best_link_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Best Link Id"},"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"cumulative_lift":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cumulative Lift"},"direction":{"enum":["maximize","minimize"],"title":"Direction","type":"string"},"links":{"items":{"$ref":"#/components/schemas/StudyChainLink"},"title":"Links","type":"array"},"proposal_id_for_best_link":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Proposal Id For Best Link"},"stop_reason":{"enum":["depth_exhausted","no_lift","budget","parent_failed","cancelled","in_flight"],"title":"Stop Reason","type":"string"}},"required":["anchor_study_id","best_link_id","best_metric","cumulative_lift","direction","stop_reason","proposal_id_for_best_link","links"],"title":"StudyChainResponse","type":"object"},"StudyConfigSpec":{"description":"Wire shape of ``studies.config`` (write-side).\n\nThe model_validator below enforces that at least one stop condition is\nset — otherwise the study has no terminating condition (FR-4).\n``parallelism`` / ``trial_timeout_s`` are optional; when absent the\nworker reads ``Settings.studies_default_parallelism`` /\n``studies_default_timeout_s`` at job time. The API layer does NOT\nmaterialize these fields into the stored row — see Story 1.5 +\nStory 3.3's ``config.model_dump(exclude_none=True, exclude_unset=True)``\ncontract.","properties":{"auto_followup_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Auto Followup Depth"},"auto_followup_strategy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Auto Followup Strategy"},"baseline_params":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Baseline Params"},"max_trials":{"anyOf":[{"maximum":100000.0,"minimum":1.0,"type":"integer"},{"type":"null"}],"title":"Max Trials"},"parallelism":{"anyOf":[{"maximum":64.0,"minimum":1.0,"type":"integer"},{"type":"null"}],"title":"Parallelism"},"pruner":{"anyOf":[{"enum":["median","none"],"type":"string"},{"type":"null"}],"title":"Pruner"},"sampler":{"anyOf":[{"enum":["tpe","random"],"type":"string"},{"type":"null"}],"title":"Sampler"},"secondary_metrics":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Secondary Metrics"},"seed":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Seed"},"time_budget_min":{"anyOf":[{"exclusiveMinimum":0.0,"type":"number"},{"type":"null"}],"title":"Time Budget Min"},"trial_timeout_s":{"anyOf":[{"maximum":3600.0,"minimum":5.0,"type":"integer"},{"type":"null"}],"title":"Trial Timeout S"}},"title":"StudyConfigSpec","type":"object"},"StudyConvergenceShape":{"description":"Verdict + supporting numerics for the UI panel and the digest narrative.\n\nMirrors the ``ConfidenceShape`` pattern from ``confidence.py``: the\ndomain module owns the Pydantic model, and ``backend.app.api.v1.schemas``\nre-exports it for the ``StudyDetail.convergence`` field. The\n``best_so_far_curve`` is the chart's data series; ``verdict`` is the\nbadge label.\n\n**Name discipline (plan §0).** The bare class name ``ConvergenceShape``\nis already taken by :class:`backend.app.domain.study.confidence.ConvergenceShape`\n(a different concept — winner-trial *timing*, not metric plateau).\n``StudyConvergenceShape`` is the study-level analogue; the confidence\nsub-shape stays on its inner module. The two coexist on ``StudyDetail``\n(``confidence.convergence`` is the inner one; ``convergence`` is this\none), and FastAPI emits both under their bare class names in the\nOpenAPI schema — no fully-qualified disambiguation noise leaks to the\nfrontend.","properties":{"best_so_far_curve":{"items":{"$ref":"#/components/schemas/CurvePoint"},"title":"Best So Far Curve","type":"array"},"direction":{"enum":["maximize","minimize"],"title":"Direction","type":"string"},"epsilon":{"title":"Epsilon","type":"number"},"improvement_in_window":{"title":"Improvement In Window","type":"number"},"total_complete_trials":{"title":"Total Complete Trials","type":"integer"},"verdict":{"enum":["converged","still_improving","too_few_trials"],"title":"Verdict","type":"string"},"warmup_floor":{"title":"Warmup Floor","type":"integer"},"window_size":{"title":"Window Size","type":"integer"}},"required":["verdict","direction","window_size","epsilon","warmup_floor","total_complete_trials","improvement_in_window","best_so_far_curve"],"title":"StudyConvergenceShape","type":"object"},"StudyDetail":{"description":"``GET /api/v1/studies/{id}`` response + ``POST/cancel`` response.","properties":{"baseline_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Baseline Metric"},"baseline_trial_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Baseline Trial Id"},"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"best_trial_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Best Trial Id"},"cluster_id":{"title":"Cluster Id","type":"string"},"completed_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Completed At"},"confidence":{"anyOf":[{"$ref":"#/components/schemas/ConfidenceShape"},{"type":"null"}]},"config":{"additionalProperties":true,"title":"Config","type":"object"},"convergence":{"anyOf":[{"$ref":"#/components/schemas/StudyConvergenceShape"},{"type":"null"}]},"created_at":{"format":"date-time","title":"Created At","type":"string"},"failed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failed Reason"},"id":{"title":"Id","type":"string"},"judgment_list_id":{"title":"Judgment List Id","type":"string"},"name":{"title":"Name","type":"string"},"objective":{"additionalProperties":true,"title":"Objective","type":"object"},"optuna_study_name":{"title":"Optuna Study Name","type":"string"},"parent_study_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Study Id"},"query_set_id":{"title":"Query Set Id","type":"string"},"search_space":{"additionalProperties":true,"title":"Search Space","type":"object"},"started_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Started At"},"status":{"enum":["queued","running","completed","cancelled","failed"],"title":"Status","type":"string"},"target":{"title":"Target","type":"string"},"template_id":{"title":"Template Id","type":"string"},"trials_summary":{"$ref":"#/components/schemas/TrialsSummaryShape"}},"required":["id","name","cluster_id","target","template_id","query_set_id","judgment_list_id","search_space","objective","config","status","failed_reason","optuna_study_name","parent_study_id","baseline_metric","baseline_trial_id","best_metric","best_trial_id","created_at","started_at","completed_at","trials_summary"],"title":"StudyDetail","type":"object"},"StudyListResponse":{"description":"``GET /api/v1/studies`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/StudySummary"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"StudyListResponse","type":"object"},"StudySummary":{"description":"List-view shape.","properties":{"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"cluster_id":{"title":"Cluster Id","type":"string"},"completed_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Completed At"},"convergence_verdict":{"anyOf":[{"enum":["converged","still_improving","too_few_trials"],"type":"string"},{"type":"null"}],"title":"Convergence Verdict"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"direction":{"default":"maximize","enum":["maximize","minimize"],"title":"Direction","type":"string"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"status":{"enum":["queued","running","completed","cancelled","failed"],"title":"Status","type":"string"},"trial_count":{"default":0,"title":"Trial Count","type":"integer"}},"required":["id","name","cluster_id","status","best_metric","created_at","completed_at"],"title":"StudySummary","type":"object"},"Subsystems":{"description":"Per-subsystem reachability/configuration state. Wire values per spec §7.4.","properties":{"db":{"description":"Postgres reachability","enum":["ok","down"],"title":"Db","type":"string"},"elasticsearch":{"description":"Local Elasticsearch container reachability","enum":["reachable","unreachable"],"title":"Elasticsearch","type":"string"},"elasticsearch_clusters":{"$ref":"#/components/schemas/ClusterAggregateHealth","description":"Aggregate health of user-registered clusters (infra_adapter_elastic Story 3.5 / spec §2). registered=0 → all-zero counts; informational only — does NOT trigger overall `degraded`."},"openai":{"description":"OpenAI key + capability state. 'incapable' added per FR-2 vs. spec §7.4 enum table — see implementation_plan.md §13 Review log.","enum":["configured","missing_key","incapable"],"title":"Openai","type":"string"},"opensearch":{"description":"Local OpenSearch container reachability","enum":["reachable","unreachable"],"title":"Opensearch","type":"string"},"redis":{"description":"Redis reachability","enum":["ok","down"],"title":"Redis","type":"string"},"solr":{"default":"not_configured","description":"Local Apache Solr container reachability. 'not_configured' when SOLR_HOST is unset (operator opted out of running the Solr service). Added by infra_adapter_solr Story A10 / spec FR-12a.","enum":["reachable","unreachable","not_configured"],"title":"Solr","type":"string"}},"required":["db","redis","openai","elasticsearch","opensearch","elasticsearch_clusters"],"title":"Subsystems","type":"object"},"SwapTemplateFollowup":{"additionalProperties":false,"description":"A 'swap_template' followup — re-run against a different query template.\n\nCarries the LLM-proposed bounds for params shared with the parent template\nin ``search_space``. The digest worker calls\n:func:`backend.app.domain.study.template_swap.remap_search_space_for_swap_target`\nafter parsing to merge these bounds with heuristic defaults for any\nswap-target params not shared with the parent.\n\nOwner: ``feat_digest_executable_followups_swap_template`` (Tier B).","properties":{"kind":{"const":"swap_template","title":"Kind","type":"string"},"rationale":{"title":"Rationale","type":"string"},"search_space":{"$ref":"#/components/schemas/SearchSpace"},"template_id":{"maxLength":36,"minLength":36,"title":"Template Id","type":"string"}},"required":["kind","rationale","template_id","search_space"],"title":"SwapTemplateFollowup","type":"object"},"TargetInfo":{"description":"One target (index / collection) on a cluster.","properties":{"doc_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Doc Count"},"name":{"title":"Name","type":"string"}},"required":["name"],"title":"TargetInfo","type":"object"},"TargetListResponse":{"description":"Response for ``GET /api/v1/clusters/{cluster_id}/targets`` (FR-1).\n\nUnpaginated by design — see feature_spec.md §7.1 \"pagination shape\nrationale\". The single-resource lookup pattern matches\n``/clusters/{id}/schema`` rather than the queryable ``/clusters`` list.\n``EntitySelectListPage``'s ``next_cursor`` and ``has_more`` fields\nare optional, so this bare ``data``-only shape consumes correctly on\nthe frontend without pretending to be a cursor endpoint.","properties":{"data":{"items":{"$ref":"#/components/schemas/TargetInfo"},"title":"Data","type":"array"}},"required":["data"],"title":"TargetListResponse","type":"object"},"TextFollowup":{"additionalProperties":false,"description":"A free-form textual suggestion — no auto-prefill, operator interprets.","properties":{"kind":{"const":"text","title":"Kind","type":"string"},"rationale":{"title":"Rationale","type":"string"},"search_space":{"title":"Search Space","type":"null"}},"required":["kind","rationale"],"title":"TextFollowup","type":"object"},"TrialDetail":{"description":"``GET /api/v1/studies/{id}/trials`` response row.","properties":{"duration_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration Ms"},"ended_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Ended At"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"id":{"title":"Id","type":"string"},"is_baseline":{"default":false,"title":"Is Baseline","type":"boolean"},"metrics":{"additionalProperties":true,"title":"Metrics","type":"object"},"optuna_trial_number":{"title":"Optuna Trial Number","type":"integer"},"params":{"additionalProperties":true,"title":"Params","type":"object"},"primary_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Primary Metric"},"started_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Started At"},"status":{"enum":["complete","failed","pruned"],"title":"Status","type":"string"},"study_id":{"title":"Study Id","type":"string"}},"required":["id","study_id","optuna_trial_number","params","primary_metric","metrics","duration_ms","status","error","started_at","ended_at"],"title":"TrialDetail","type":"object"},"TrialListResponse":{"description":"``GET /api/v1/studies/{id}/trials`` response.","properties":{"data":{"items":{"$ref":"#/components/schemas/TrialDetail"},"title":"Data","type":"array"},"has_more":{"title":"Has More","type":"boolean"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"required":["data","next_cursor","has_more"],"title":"TrialListResponse","type":"object"},"TrialsSummaryShape":{"description":"The ``trials_summary`` field embedded in :class:`StudyDetail`.","properties":{"best_primary_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Primary Metric"},"complete":{"title":"Complete","type":"integer"},"failed":{"title":"Failed","type":"integer"},"pruned":{"title":"Pruned","type":"integer"},"total":{"title":"Total","type":"integer"}},"required":["total","complete","failed","pruned","best_primary_metric"],"title":"TrialsSummaryShape","type":"object"},"UbiReadinessResponse":{"description":"``GET /api/v1/clusters/{cluster_id}/ubi-readiness`` response (FR-7).\n\n``covered_pairs_pct`` and ``head_covered`` are nullable — MVP2's\nrung classifier uses event-count thresholds (the SearchAdapter\nProtocol doesn't expose an exact ``_count`` endpoint). The fields\nare reserved on the wire so a future ``infra_adapter_count_method``\ncan fill them without breaking the contract. See\n:mod:`backend.app.services.ubi_readiness` for the rationale.","properties":{"checked_at":{"format":"date-time","title":"Checked At","type":"string"},"covered_pairs_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Covered Pairs Pct"},"head_covered":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Head Covered"},"rung":{"enum":["rung_0","rung_1","rung_2","rung_3"],"title":"Rung","type":"string"}},"required":["rung","covered_pairs_pct","head_covered","checked_at"],"title":"UbiReadinessResponse","type":"object"},"UpdateQueryRequest":{"additionalProperties":false,"description":"``PATCH /api/v1/query-sets/{set_id}/queries/{query_id}`` body.\n\nWhole-object replace on ``query_metadata`` (NOT deep-merge); explicit\n``null`` removes a nullable field; omitted key = no change. Empty\nbody ``{}`` validates as a no-op (AC-28).\n\n``query_text`` is NOT NULL on the underlying table, so explicit-null\nis rejected by the ``@model_validator`` below (a 422 surfaces sooner\nthan the SQL ``NotNullViolation``).","properties":{"query_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Query Metadata"},"query_text":{"anyOf":[{"maxLength":4000,"minLength":1,"type":"string"},{"type":"null"}],"title":"Query Text"},"reference_answer":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reference Answer"}},"title":"UpdateQueryRequest","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"},"WidenFollowup":{"additionalProperties":false,"description":"A 'widen' followup — re-run with a broader range than the parent.","properties":{"kind":{"const":"widen","title":"Kind","type":"string"},"rationale":{"title":"Rationale","type":"string"},"search_space":{"$ref":"#/components/schemas/SearchSpace"}},"required":["kind","rationale","search_space"],"title":"WidenFollowup","type":"object"},"_ClusterEmbed":{"description":"Inline cluster summary on proposal responses.","properties":{"engine_type":{"title":"Engine Type","type":"string"},"environment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"}},"required":["id","name","engine_type"],"title":"_ClusterEmbed","type":"object"},"_DigestEmbed":{"description":"Inline digest summary on the proposal-detail response.\n\nfeat_digest_executable_followups Story 4.1 — ``suggested_followups`` is\nnow a discriminated-union list (see ``DigestResponse``).","properties":{"generated_at":{"format":"date-time","title":"Generated At","type":"string"},"id":{"title":"Id","type":"string"},"narrative":{"title":"Narrative","type":"string"},"parameter_importance":{"additionalProperties":{"type":"number"},"title":"Parameter Importance","type":"object"},"recommended_config":{"additionalProperties":true,"title":"Recommended Config","type":"object"},"suggested_followups":{"items":{"$ref":"#/components/schemas/FollowupItem"},"title":"Suggested Followups","type":"array"}},"required":["id","narrative","parameter_importance","recommended_config","suggested_followups","generated_at"],"title":"_DigestEmbed","type":"object"},"_SourceBreakdown":{"description":"Source-breakdown sub-shape on :class:`JudgmentListDetail`.\n\nEvolved 2026-05-29 by ``feat_ubi_judgments`` FR-10 — now three terms\n(``llm + human + click == judgment_count``). The cycle-2 F6\n\"click folds into human\" contract is superseded the moment UBI ships\nclick rows; the UI's source-breakdown card now renders all three\nbuckets separately so operators see the mix at a glance.","properties":{"click":{"title":"Click","type":"integer"},"human":{"title":"Human","type":"integer"},"llm":{"title":"Llm","type":"integer"}},"required":["llm","human","click"],"title":"_SourceBreakdown","type":"object"},"_StudySummary":{"description":"Inline study summary on the proposal-detail response.","properties":{"best_metric":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Best Metric"},"best_trial_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Best Trial Id"},"id":{"title":"Id","type":"string"},"judgment_list":{"additionalProperties":true,"title":"Judgment List","type":"object"},"name":{"title":"Name","type":"string"},"query_set":{"additionalProperties":true,"title":"Query Set","type":"object"},"status":{"title":"Status","type":"string"}},"required":["id","name","status","best_metric","best_trial_id","query_set","judgment_list"],"title":"_StudySummary","type":"object"},"_TemplateEmbed":{"description":"Inline template summary on proposal responses.","properties":{"engine_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Engine Type"},"id":{"title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"version":{"title":"Version","type":"integer"}},"required":["id","name","version"],"title":"_TemplateEmbed","type":"object"}}},"info":{"description":"Open-source automated relevance tuning for enterprise search platforms","title":"RelyLoop","version":"0.1.0"},"openapi":"3.1.0","paths":{"/api/v1/_test/auto-followup/seed-chain":{"post":{"description":"Test-only endpoint. Returns 404 unless `ENVIRONMENT=development`. Inserts a chain of `depth + 1` studies where each child carries the prior node's id as `parent_study_id`. The public POST /studies endpoint does NOT accept `parent_study_id` (it's set only by the auto-followup worker via `repo.create_study(parent_study_id=...)`), so this endpoint is the only way to drive deterministic E2E coverage of chain-panel parent-link / children-table / cascade-radio paths. Closes chore_auto_followup_e2e_chain_seed_helper.","operationId":"seed_auto_followup_chain_endpoint_api_v1__test_auto_followup_seed_chain_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeedAutoFollowupChainRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeedAutoFollowupChainResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Seed an auto-followup chain of N+1 linked studies","tags":["test-only"]}},"/api/v1/_test/demo/reseed":{"post":{"description":"Enqueues an Arq job that wipes the demo Postgres tables + ES/OS indices, then re-seeds the 4 demo scenarios from ``scripts/seed_meaningful_demos.py`` using REAL studies (real Optuna trials, real metrics per scenario). Returns 202 + an initial ``ReseedStatusResponse`` immediately; the frontend polls ``GET /api/v1/_test/demo/reseed/status`` for progress.\n\nPer ``bug_demo_reseed_fake_metric_regression``. Replaces the previous synchronous path that called ``/_test/studies/seed-completed`` and produced identical ``best_metric=0.487`` rows for every scenario.","operationId":"reseed_demo_api_v1__test_demo_reseed_post","responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReseedStatusResponse"}}},"description":"Successful Response"}},"summary":"Enqueue a demo-state reseed (dev-only, async)","tags":["test-only"]}},"/api/v1/_test/demo/reseed/status":{"get":{"description":"Returns the current reseed status from Redis. When no reseed has ever run (or the result TTL'd out), returns ``{status: 'idle'}`` rather than 404 so the frontend's polling loop is trivially safe.","operationId":"reseed_demo_status_api_v1__test_demo_reseed_status_get","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReseedStatusResponse"}}},"description":"Successful Response"}},"summary":"Poll the current demo-reseed progress (dev-only)","tags":["test-only"]}},"/api/v1/_test/digests/{digest_id}":{"delete":{"description":"FR-2: Hard-delete the digest row. No FK children — no preflight needed.","operationId":"delete_test_digest_api_v1__test_digests__digest_id__delete","parameters":[{"in":"path","name":"digest_id","required":true,"schema":{"title":"Digest Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a digest (test-only)","tags":["test-only"]}},"/api/v1/_test/judgment-lists/{judgment_list_id}":{"delete":{"description":"FR-4 — hard-delete the judgment_list row.\n\nJudgments cascade-delete via existing FK. Preflight-checks ``studies``\n(non-cascade); 409 if any study references the judgment_list.","operationId":"delete_test_judgment_list_api_v1__test_judgment_lists__judgment_list_id__delete","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a judgment_list (test-only)","tags":["test-only"]}},"/api/v1/_test/proposals/{proposal_id}":{"delete":{"description":"FR-1: Hard-delete the proposal row. No FK children — no preflight needed.","operationId":"delete_test_proposal_api_v1__test_proposals__proposal_id__delete","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a proposal (test-only)","tags":["test-only"]}},"/api/v1/_test/query-sets/{query_set_id}":{"delete":{"description":"FR-5 — hard-delete the query_set row.\n\nQueries cascade-delete via existing FK. Preflight-checks ``studies``\n+ ``judgment_lists`` (both non-cascade); 409 with resource-specific\ncode if either references.","operationId":"delete_test_query_set_api_v1__test_query_sets__query_set_id__delete","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a query_set (test-only)","tags":["test-only"]}},"/api/v1/_test/query-templates/{template_id}":{"delete":{"description":"FR-6 — hard-delete the query_template row.\n\nNo FK children cascade with template. Preflight-checks ``studies``,\n``proposals``, and ``judgment_lists.current_template_id`` in\n**fixed priority order: STUDY > PROPOSAL > JUDGMENT_LIST** (per\nspec §FR-6) — first match wins.","operationId":"delete_test_query_template_api_v1__test_query_templates__template_id__delete","parameters":[{"in":"path","name":"template_id","required":true,"schema":{"title":"Template Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a query_template (test-only)","tags":["test-only"]}},"/api/v1/_test/studies/seed-completed":{"post":{"description":"Test-only endpoint. Returns 404 unless `ENVIRONMENT=development`. Inserts a study (driven through queued → running → completed via the legal state-machine transitions), 2 trials (one winner, one comparison), a digest, and optionally a pending proposal in a single transaction. Used by the Playwright E2E suite to cover the digest-panel surfaces (7 tooltip placements + AC-7 body content + AC-11 Open PR enabled/disabled branches) without waiting on the orchestrator + Optuna workers.","operationId":"seed_completed_study_api_v1__test_studies_seed_completed_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeedCompletedStudyRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeedCompletedStudyResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Seed a completed study + digest + (optional) pending proposal","tags":["test-only"]}},"/api/v1/_test/studies/{study_id}":{"delete":{"description":"FR-3 — hard-delete the study row.\n\nTrials cascade-delete via existing FK. Preflight-checks ``proposals``\n+ ``digests`` (both non-cascade); 409 if any dependent rows reference\nthe study.","operationId":"delete_test_study_api_v1__test_studies__study_id__delete","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Hard-delete a study (test-only)","tags":["test-only"]}},"/api/v1/clusters":{"get":{"description":"List clusters with cursor pagination + ``X-Total-Count`` header.\n\n``?q=`` is a Postgres FTS match against the cluster's ``search_vector``\n(name + base_url); 2–200 chars. Filter-only — ordering unchanged per\nspec FR-1. ``?sort=`` is one of the values in\n:data:`~backend.app.api.v1.schemas.ClusterSortKey`; the cursor is\nsort-aware so the keyset predicate matches the active ORDER BY\n(feat_data_table_primitive Stories 1.2 + 1.3).","operationId":"list_clusters_api_v1_clusters_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc","environment:asc","environment:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}},{"in":"query","name":"engine_type","required":false,"schema":{"anyOf":[{"enum":["elasticsearch","opensearch","solr"],"type":"string"},{"type":"null"}],"title":"Engine Type"}},{"in":"query","name":"environment","required":false,"schema":{"anyOf":[{"enum":["prod","staging","dev"],"type":"string"},{"type":"null"}],"title":"Environment"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Clusters","tags":["clusters"]},"post":{"description":"Register a cluster (FR-5 / AC-1).","operationId":"create_cluster_api_v1_clusters_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateClusterRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Cluster","tags":["clusters"]}},"/api/v1/clusters/test-connection":{"post":{"description":"Probe a cluster config WITHOUT persisting (infra_adapter_solr Story A9).\n\nPowers the registration modal's \"Test connection\" button. Always 200 —\ntransport failures surface as ``reachable=false`` with ``error`` set.\nInvalid engine×auth pairings 400 BEFORE the network call.","operationId":"test_connection_api_v1_clusters_test_connection_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectionTestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectionTestResult"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Test Connection","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}":{"delete":{"description":"Soft-delete a cluster (AC-8). Returns 204 with no body.","operationId":"delete_cluster_api_v1_clusters__cluster_id__delete","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Delete Cluster","tags":["clusters"]},"get":{"description":"Return cluster row + cached/fresh health probe.","operationId":"get_cluster_detail_api_v1_clusters__cluster_id__get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Cluster Detail","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/reprobe":{"post":{"description":"Re-run cluster capability probe (Story A9 / spec FR-2 + AC-14).\n\nConcurrent calls serialize on ``SELECT … FOR UPDATE``. On probe failure\nthe row's engine_config is NOT updated (the transaction rolls back).","operationId":"reprobe_cluster_api_v1_clusters__cluster_id__reprobe_post","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Reprobe Cluster","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/run_query":{"post":{"description":"Execute one query DSL fragment against the cluster (FR-6 / AC-3).","operationId":"run_query_api_v1_clusters__cluster_id__run_query_post","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"query","name":"timeout_s","required":false,"schema":{"default":5.0,"maximum":30.0,"minimum":1.0,"title":"Timeout S","type":"number"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunQueryRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunQueryResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Run Query","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/schema":{"get":{"description":"Return the field schema for ``target`` (FR-4 / AC-2).","operationId":"get_cluster_schema_api_v1_clusters__cluster_id__schema_get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"query","name":"target","required":true,"schema":{"maxLength":256,"minLength":1,"title":"Target","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Schema"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Cluster Schema","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/targets":{"get":{"description":"List targets (indices/collections) on the cluster (FR-1 / AC-1).\n\nThin passthrough to ``ElasticAdapter.list_targets()`` (which filters out\nsystem indices whose names start with ``.``). Mirrors the ``get_cluster_schema``\npattern: ``get_cluster`` → ``acquire_adapter`` async context → adapter call\n→ translate exceptions via the ``_err()`` helper to the spec §7.5 envelope.\n\nError mapping:\n* cluster missing or soft-deleted → 404 ``CLUSTER_NOT_FOUND`` (retryable=false)\n* adapter raises ``TargetsForbiddenError`` (ACL 401/403) → 403\n ``TARGETS_FORBIDDEN`` (retryable=false) — frontend auto-engages manual mode\n* adapter raises ``ClusterUnreachableError`` (5xx / connection failure) → 503\n ``CLUSTER_UNREACHABLE`` (retryable=true)","operationId":"list_cluster_targets_api_v1_clusters__cluster_id__targets_get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TargetListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Cluster Targets","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/targets/{target}/documents":{"get":{"description":"Paginated _id + truncated _source preview for a target (FR-3).\n\nThe endpoint asks the adapter for ``limit + 1`` rows so it can detect\nend-of-data exactly (no extra round-trip). Only the first ``limit`` rows\nare returned; ``next_cursor`` encodes the ES ``hits[i].sort`` of the\nlast visible row when ``has_more`` is True. ``X-Total-Count`` header\ncarries the engine's ``hits.total.value``.","operationId":"list_target_documents_api_v1_clusters__cluster_id__targets__target__documents_get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"path","name":"target","required":true,"schema":{"title":"Target","type":"string"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"maxLength":4096,"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":25,"maximum":100,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"fields","required":false,"schema":{"anyOf":[{"maxLength":2048,"type":"string"},{"type":"null"}],"title":"Fields"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Target Documents","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/targets/{target}/documents/{doc_id}":{"get":{"description":"Fetch one document by ``_id`` (FR-4).\n\nFastAPI's ``{doc_id:path}`` converter round-trips slashes verbatim, so\noperator IDs containing ``/`` are supported (D-17 / AC-16). Returns the\nadapter ``Document`` shape directly; on ``found: false`` returns 404\n``DOCUMENT_NOT_FOUND`` (distinct from ``TARGET_NOT_FOUND``).","operationId":"get_target_document_api_v1_clusters__cluster_id__targets__target__documents__doc_id__get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"path","name":"target","required":true,"schema":{"title":"Target","type":"string"}},{"in":"path","name":"doc_id","required":true,"schema":{"title":"Doc Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Target Document","tags":["clusters"]}},"/api/v1/clusters/{cluster_id}/ubi-readiness":{"get":{"description":"Classify ``(cluster, query_set, target)`` on the UBI rung ladder.\n\nfeat_ubi_judgments FR-7.\n\nRequired query params: ``query_set_id`` + ``target`` (Spec FR-7 +\ncycle-3 D-10c: the endpoint MUST 422 without them — the classifier\ncan't compute a per-target rung without an application filter).\n\nError envelopes (all per spec §7.5):\n* ``404 CLUSTER_NOT_FOUND`` — cluster row missing or soft-deleted.\n* ``404 QUERY_SET_NOT_FOUND`` — query set row missing.\n* ``422 VALIDATION_ERROR`` — missing required query params (FastAPI's\n built-in handler, surfaces via ``api/errors.py``).\n* ``503 CLUSTER_UNREACHABLE`` — adapter cannot reach the cluster.\n\nThe result is cached for 60 s in Redis per\n``(cluster_id, query_set_id, target)`` so back-to-back dialog-open\nand dialog-submit calls don't re-probe.","operationId":"get_cluster_ubi_readiness_api_v1_clusters__cluster_id__ubi_readiness_get","parameters":[{"in":"path","name":"cluster_id","required":true,"schema":{"title":"Cluster Id","type":"string"}},{"in":"query","name":"query_set_id","required":true,"schema":{"maxLength":36,"minLength":1,"title":"Query Set Id","type":"string"}},{"in":"query","name":"target","required":true,"schema":{"maxLength":256,"minLength":1,"title":"Target","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UbiReadinessResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Cluster Ubi Readiness","tags":["clusters"]}},"/api/v1/config-repos":{"get":{"description":"Cursor-paginated config-repo list, newest first.","operationId":"list_config_repos_endpoint_api_v1_config_repos_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigReposListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Config Repos Endpoint","tags":["config-repos"]},"post":{"description":"Register a new config repo. ``provider`` is server-derived from ``repo_url``.\n\nPreflight order matches spec FR-3:\n\n1. ``validate_repo_url(repo_url)`` → 400 ``UNSUPPORTED_PROVIDER`` for\n non-GitHub URLs (AC-8). GitLab + Bitbucket arrive at MVP3.\n2. ``./secrets/{auth_ref}`` must exist → else 400 ``AUTH_REF_NOT_FOUND``\n (AC-9). The contents check defers to the worker — operators may\n populate the file between registration and first PR-open.\n3. ``name`` uniqueness check → 409 ``CONFIG_REPO_NAME_TAKEN`` on collision.\n4. Insert with server-derived ``provider=\"github\"``.\n5. **feat_github_webhook Story 4.2** — when ``webhook_secret_ref`` is\n populated, best-effort enqueue ``register_webhook`` against the\n newly created config_repo id. Enqueue failure (Redis down, pool\n absent, transient blip) does NOT break the 201 — it logs WARN\n and the operator drives recovery via the runbook.","operationId":"create_config_repo_endpoint_api_v1_config_repos_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateConfigRepoRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigRepoDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Config Repo Endpoint","tags":["config-repos"]}},"/api/v1/config-repos/{config_repo_id}":{"get":{"description":"Detail by id; 404 ``CONFIG_REPO_NOT_FOUND`` if missing.\n\nfeat_config_repo_baseline_tracking FR-4 — when\n``last_merged_proposal_id`` is set, embed the pointed-at proposal as a\n:class:`ProposalSummary` with ``is_currently_live=True``. The embed-side\nderivation uses the pointer context directly (NOT the generic\n``proposals → clusters → config_repos`` JOIN used elsewhere) so the\nbadge renders correctly even when the proposal's cluster was later\nunwired from this config_repo (spec §19 \"Cluster-with-config_repo-\nrotated\" decision-log entry).","operationId":"get_config_repo_endpoint_api_v1_config_repos__config_repo_id__get","parameters":[{"in":"path","name":"config_repo_id","required":true,"schema":{"title":"Config Repo Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigRepoDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Config Repo Endpoint","tags":["config-repos"]}},"/api/v1/conversations":{"get":{"description":"List conversations newest-first with per-row message_count + X-Total-Count header.\n\n``?since=`` (Story 1.5 — closes api-conventions.md drift) filters by\n``created_at >= since``. ``?q=`` (Story 1.2) is a Postgres FTS match\nagainst ``search_vector`` (coalesce(title, '')); 2-200 chars.","operationId":"list_conversations_endpoint_api_v1_conversations_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationsListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Conversations Endpoint","tags":["conversations"]},"post":{"description":"Create a new conversation. Title is optional (FR-1 auto-generates from first message).","operationId":"create_conversation_endpoint_api_v1_conversations_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateConversationRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationSummary"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Conversation Endpoint","tags":["conversations"]}},"/api/v1/conversations/{conversation_id}":{"delete":{"description":"Soft-delete the conversation; subsequent reads return 404.","operationId":"delete_conversation_endpoint_api_v1_conversations__conversation_id__delete","parameters":[{"in":"path","name":"conversation_id","required":true,"schema":{"title":"Conversation Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Delete Conversation Endpoint","tags":["conversations"]},"get":{"description":"Return the conversation's full message history.","operationId":"get_conversation_endpoint_api_v1_conversations__conversation_id__get","parameters":[{"in":"path","name":"conversation_id","required":true,"schema":{"title":"Conversation Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Conversation Endpoint","tags":["conversations"]}},"/api/v1/conversations/{conversation_id}/messages":{"post":{"description":"Send a user message and stream the assistant turn as SSE.\n\nPreflight (in order; returns plain JSON envelope, NOT a partial stream):\n A. Conversation exists → else 404 ``CONVERSATION_NOT_FOUND``.\n B. ``Settings.openai_api_key`` populated → else 503 ``OPENAI_NOT_CONFIGURED``.\n C. Daily budget peek under cap → else 503 ``OPENAI_BUDGET_EXCEEDED``.\n\nSuccessful preflight returns a ``StreamingResponse(text/event-stream)``\ndriven by :func:`agent_chat.send_user_message`.","operationId":"post_message_endpoint_api_v1_conversations__conversation_id__messages_post","parameters":[{"in":"path","name":"conversation_id","required":true,"schema":{"title":"Conversation Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Post Message Endpoint","tags":["conversations"]}},"/api/v1/judgment-lists":{"get":{"description":"List judgment lists, newest-first with cursor pagination.\n\n``?since=`` filters by ``created_at >= since`` (Story 1.5). ``?q=`` FTS\nmatch against ``search_vector`` (name + target). ``?sort=`` is a\n:data:`JudgmentListSortKey` value with sort-aware cursor (Story 1.3).\n``?query_set_id`` / ``?cluster_id`` filter to lists belonging to the\nsupplied parent (``bug_judgment_lists_listing_ignores_query_set_filter``\n— required by the create-study modal's Step-2 dropdown so the user\ncan only pick judgment-lists valid for the chosen query-set + cluster;\nwithout these filters the modal returns all rows and the user can\npick a mismatched pair, which the ``POST /api/v1/studies`` cross-\nentity integrity check then rejects at create time with a confusing\n422 ``VALIDATION_ERROR: \"judgment_list query_set_id does not match\nstudy query_set_id\"``).\n\n``?target=`` filters by exact target index/collection name\n(``feat_study_target_judgment_mismatch_guard`` FR-2 — pairs with the\n``POST /studies`` ``JUDGMENT_TARGET_MISMATCH`` 422 so the create-study\nmodal can pre-filter the dropdown to only lists matching the chosen\nstudy target). Bounded by the ES/OpenSearch index-name ceiling\n(255 bytes).","operationId":"list_judgment_lists_endpoint_api_v1_judgment_lists_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc","status:asc","status:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}},{"in":"query","name":"query_set_id","required":false,"schema":{"anyOf":[{"maxLength":36,"minLength":1,"type":"string"},{"type":"null"}],"title":"Query Set Id"}},{"in":"query","name":"cluster_id","required":false,"schema":{"anyOf":[{"maxLength":36,"minLength":1,"type":"string"},{"type":"null"}],"title":"Cluster Id"}},{"in":"query","name":"target","required":false,"schema":{"anyOf":[{"maxLength":255,"minLength":1,"type":"string"},{"type":"null"}],"title":"Target"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentListListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Judgment Lists Endpoint","tags":["judgments"]}},"/api/v1/judgment-lists/import":{"post":{"description":"Create a judgment_lists row with status='complete' + bulk-insert judgments.\n\nTutorial path; no OpenAI involvement. Every supplied judgment must\nreference a ``query_id`` that exists in ``body.query_set_id`` —\nmismatches → 400 ``QUERY_NOT_IN_SET``.","operationId":"import_judgment_list_api_v1_judgment_lists_import_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportJudgmentListRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentListDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Import Judgment List","tags":["judgments"]}},"/api/v1/judgment-lists/{judgment_list_id}":{"get":{"operationId":"get_judgment_list_endpoint_api_v1_judgment_lists__judgment_list_id__get","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentListDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Judgment List Endpoint","tags":["judgments"]}},"/api/v1/judgment-lists/{judgment_list_id}/calibration":{"post":{"description":"Compute Cohen's + weighted kappa from supplied human samples.\n\nPairs are built by joining each sample with the existing\n``source='llm'`` judgment at ``(query_id, doc_id)`` — overridden rows\n(``source='human'``) are excluded (per spec FR-5 + GPT-5.5 cycle 1 F12).","operationId":"calibrate_judgment_list_api_v1_judgment_lists__judgment_list_id__calibration_post","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalibrationSamplesRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalibrationResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Calibrate Judgment List","tags":["judgments"]}},"/api/v1/judgment-lists/{judgment_list_id}/judgments":{"get":{"description":"List per-list judgments with cursor pagination.\n\n``?sort=`` is :data:`JudgmentRowSortKey` with sort-aware cursor\n(feat_data_table_primitive Story 1.3).","operationId":"list_judgments_endpoint_api_v1_judgment_lists__judgment_list_id__judgments_get","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}},{"in":"query","name":"source","required":false,"schema":{"anyOf":[{"enum":["llm","human","click"],"type":"string"},{"type":"null"}],"title":"Source"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["created_at:asc","created_at:desc","rating:asc","rating:desc","source:asc","source:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentListJudgmentsResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Judgments Endpoint","tags":["judgments"]}},"/api/v1/judgment-lists/{judgment_list_id}/judgments/{judgment_id}":{"patch":{"description":"Replace an LLM rating with a human override (UPSERT-replace).","operationId":"override_judgment_api_v1_judgment_lists__judgment_list_id__judgments__judgment_id__patch","parameters":[{"in":"path","name":"judgment_list_id","required":true,"schema":{"title":"Judgment List Id","type":"string"}},{"in":"path","name":"judgment_id","required":true,"schema":{"title":"Judgment Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OverrideJudgmentRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JudgmentRow"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Override Judgment","tags":["judgments"]}},"/api/v1/judgments/generate":{"post":{"description":"Create a judgment_lists row + enqueue the worker.\n\nDelegates the full preflight + INSERT + Arq enqueue to\n:func:`backend.app.services.agent_judgments_dispatch.start_judgment_generation`\nso the chat-agent ``generate_judgments_llm`` tool reuses the exact same\nchecks (no duplicated preflight). Wire behavior is identical — same error\ncodes, same status codes, same response shape.","operationId":"generate_judgments_api_v1_judgments_generate_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJudgmentListGenerateRequest"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateJudgmentsResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Generate Judgments","tags":["judgments"]}},"/api/v1/judgments/generate-from-ubi":{"post":{"description":"Start a UBI-derived judgment generation job.\n\nDelegates to\n:func:`backend.app.services.agent_judgments_dispatch.start_ubi_judgment_generation`\nwhich runs the full FR-4 preflight (U-A..U-H) before INSERT + Arq\nenqueue. The Pydantic ``model_validator`` on\n:class:`CreateJudgmentListFromUbiRequest` already enforces the\nhybrid conditional (``current_template_id`` + ``rubric`` required\niff ``converter == 'hybrid_ubi_llm'``); the dispatcher trusts the\nvalidated request.","operationId":"generate_judgments_from_ubi_api_v1_judgments_generate_from_ubi_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJudgmentListFromUbiRequest"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateJudgmentsResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Generate Judgments From Ubi","tags":["judgments"]}},"/api/v1/proposals":{"get":{"description":"List proposals with cursor pagination + filters.\n\n``?template_id=`` (Story 1.5) filters by ``proposals.template_id`` FK;\n``?study_id=`` filters by ``proposals.study_id`` FK (used by the\nstudy-detail page's pending-proposal lookup). Both reject invalid\nUUIDs with 422 via FastAPI's UUID parsing. ``?sort=`` (Story 1.3) is\na :data:`ProposalSortKey` value with sort-aware cursor.\n\nPhase 3 D-15 revised: ``?include_superseded`` defaults to ``False``;\nwhen ``False`` AND no ``?status=`` is set, the response omits\n``superseded`` rows. Explicit ``?status=`` always beats implicit\n``include_superseded`` (single-value backward compat preserved).","operationId":"list_proposals_endpoint_api_v1_proposals_get","parameters":[{"in":"query","name":"status","required":false,"schema":{"anyOf":[{"enum":["pending","pr_opened","pr_merged","rejected","superseded"],"type":"string"},{"type":"null"}],"title":"Status"}},{"in":"query","name":"cluster_id","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cluster Id"}},{"in":"query","name":"source","required":false,"schema":{"anyOf":[{"enum":["study","manual"],"type":"string"},{"type":"null"}],"title":"Source"}},{"in":"query","name":"template_id","required":false,"schema":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}],"title":"Template Id"}},{"in":"query","name":"study_id","required":false,"schema":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}],"title":"Study Id"}},{"in":"query","name":"is_last_merged","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Last Merged"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["created_at:asc","created_at:desc","status:asc","status:desc","pr_state:asc","pr_state:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}},{"in":"query","name":"include_superseded","required":false,"schema":{"default":false,"title":"Include Superseded","type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalsListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Proposals Endpoint","tags":["proposals"]},"post":{"description":"Manually create a proposal (chat-agent hand-crafted tweaks).\n\n``study_id`` and ``study_trial_id`` are NULL for manual proposals.\nValidates FK targets (cluster + template exist) before insert.","operationId":"create_manual_proposal_api_v1_proposals_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProposalRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Manual Proposal","tags":["proposals"]}},"/api/v1/proposals/{proposal_id}":{"get":{"operationId":"get_proposal_endpoint_api_v1_proposals__proposal_id__get","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Proposal Endpoint","tags":["proposals"]}},"/api/v1/proposals/{proposal_id}/open_pr":{"post":{"description":"Enqueue the ``open_pr`` worker for an operator-approved proposal.\n\nDelegates the full preflight + Arq enqueue to\n:func:`backend.app.services.agent_proposals_dispatch.open_pr` so the\nchat-agent ``open_pr`` tool reuses the same checks. Wire behavior is\nidentical — same error codes, status codes, response shape.","operationId":"open_pr_endpoint_api_v1_proposals__proposal_id__open_pr_post","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenPrResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Open Pr Endpoint","tags":["proposals"]}},"/api/v1/proposals/{proposal_id}/reinstate":{"post":{"description":"Phase 3 FR-6: ``superseded → pending`` transition.\n\nMirrors :func:`reject_proposal_endpoint` (D-17 — read-check-mutate so\n404 vs 409 stays deterministic). Reuses ``INVALID_STATE_TRANSITION``\nper D-16; emits ``chain_proposal_reinstated`` structlog AFTER commit\nper D-19.","operationId":"reinstate_proposal_endpoint_api_v1_proposals__proposal_id__reinstate_post","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Reinstate Proposal Endpoint","tags":["proposals"]}},"/api/v1/proposals/{proposal_id}/reject":{"post":{"description":"AC-5: ``pending → rejected`` transition; 409 INVALID_STATE_TRANSITION otherwise.","operationId":"reject_proposal_endpoint_api_v1_proposals__proposal_id__reject_post","parameters":[{"in":"path","name":"proposal_id","required":true,"schema":{"title":"Proposal Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectProposalRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Reject Proposal Endpoint","tags":["proposals"]}},"/api/v1/query-sets":{"get":{"description":"List query sets with cursor pagination + X-Total-Count.\n\n``?q=`` is FTS match against ``search_vector`` (name). ``?sort=`` is a\n:data:`QuerySetSortKey` value; cursor is sort-aware.","operationId":"list_query_sets_api_v1_query_sets_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuerySetListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Query Sets","tags":["query-sets"]},"post":{"description":"Register a query set under a cluster (FR-3).","operationId":"create_query_set_api_v1_query_sets_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuerySetRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuerySetDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Query Set","tags":["query-sets"]}},"/api/v1/query-sets/{query_set_id}":{"get":{"description":"Return a query set by id (includes ``query_count``).","operationId":"get_query_set_detail_api_v1_query_sets__query_set_id__get","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuerySetDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Query Set Detail","tags":["query-sets"]}},"/api/v1/query-sets/{query_set_id}/queries":{"get":{"description":"List per-query rows under a query set, with derived ``judgment_count``.","operationId":"list_queries_in_set_api_v1_query_sets__query_set_id__queries_get","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Queries In Set","tags":["query-sets"]},"post":{"description":"Bulk-add queries to a set (FR-3 + AC-8).\n\nDispatches on Content-Type:\n\n* ``application/json`` → :class:`BulkQueriesJsonRequest` Pydantic-parse.\n* ``text/csv`` → :func:`parse_queries_csv` (AC-8).\n\nOther content types → 415-equivalent surfaced as 400 ``INVALID_CSV``\n(the documented error code for content-type-mismatch in spec §7.5).","operationId":"bulk_add_queries_api_v1_query_sets__query_set_id__queries_post","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}}],"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkQueriesResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Bulk Add Queries","tags":["query-sets"]}},"/api/v1/query-sets/{query_set_id}/queries/{query_id}":{"delete":{"description":"Hard-delete a query. FK-guarded — 409 if any judgment references it.","operationId":"delete_query_endpoint_api_v1_query_sets__query_set_id__queries__query_id__delete","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}},{"in":"path","name":"query_id","required":true,"schema":{"title":"Query Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryHasJudgmentsEnvelope"}}},"description":"Conflict"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Delete Query Endpoint","tags":["query-sets"]},"patch":{"description":"Partial-update a query. Whole-object replace on ``query_metadata``.","operationId":"update_query_endpoint_api_v1_query_sets__query_set_id__queries__query_id__patch","parameters":[{"in":"path","name":"query_set_id","required":true,"schema":{"title":"Query Set Id","type":"string"}},{"in":"path","name":"query_id","required":true,"schema":{"title":"Query Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQueryRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRow"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Update Query Endpoint","tags":["query-sets"]}},"/api/v1/query-templates":{"get":{"description":"List query templates with cursor pagination + X-Total-Count header.\n\n``?q=`` FTS match (name). ``?sort=`` sort-aware cursor (Story 1.3).\n``?engine_type=`` filters by engine (Story 1.4).","operationId":"list_query_templates_api_v1_query_templates_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc","engine_type:asc","engine_type:desc","version:asc","version:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}},{"in":"query","name":"engine_type","required":false,"schema":{"anyOf":[{"enum":["elasticsearch","opensearch","solr"],"type":"string"},{"type":"null"}],"title":"Engine Type"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryTemplateListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Query Templates","tags":["query-templates"]},"post":{"description":"Register a query template (FR-2 + AC-7).\n\nAC-7: a body containing ``{{ os.system('rm -rf /') }}`` surfaces as\n400 ``INVALID_TEMPLATE_SYNTAX`` (the AST walk catches the ``Call``\nnode before reaching the meta-vars cross-check that would otherwise\nclassify ``os`` as ``UndeclaredParamUsed``).","operationId":"create_query_template_api_v1_query_templates_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQueryTemplateRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryTemplateDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Query Template","tags":["query-templates"]}},"/api/v1/query-templates/{template_id}":{"get":{"description":"Return a query template by id.","operationId":"get_query_template_detail_api_v1_query_templates__template_id__get","parameters":[{"in":"path","name":"template_id","required":true,"schema":{"title":"Template Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryTemplateDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Query Template Detail","tags":["query-templates"]}},"/api/v1/studies":{"get":{"description":"List studies with cursor pagination + X-Total-Count.\n\n``?status=`` is typed as :data:`StudyStatusWire` so FastAPI returns\n422 ``VALIDATION_ERROR`` for unsupported values. ``?q=`` is a Postgres\nFTS match against ``search_vector`` (name + target). ``?sort=`` is a\n:data:`StudySortKey` value (``:``); the cursor is\nsort-aware (feat_data_table_primitive Stories 1.2 + 1.3).\n\n``?target=`` (feat_index_document_browser FR-5) scopes the list to\nstudies targeting a single index/collection. Composes with all other\nfilters via AND.","operationId":"list_studies_api_v1_studies_get","parameters":[{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"status","required":false,"schema":{"anyOf":[{"enum":["queued","running","completed","cancelled","failed"],"type":"string"},{"type":"null"}],"title":"Status"}},{"in":"query","name":"cluster_id","required":false,"schema":{"anyOf":[{"maxLength":36,"minLength":1,"type":"string"},{"type":"null"}],"title":"Cluster Id"}},{"in":"query","name":"target","required":false,"schema":{"anyOf":[{"maxLength":256,"minLength":1,"type":"string"},{"type":"null"}],"title":"Target"}},{"in":"query","name":"q","required":false,"schema":{"anyOf":[{"maxLength":200,"minLength":2,"type":"string"},{"type":"null"}],"title":"Q"}},{"in":"query","name":"sort","required":false,"schema":{"anyOf":[{"enum":["name:asc","name:desc","created_at:asc","created_at:desc","completed_at:asc","completed_at:desc","best_metric:asc","best_metric:desc","status:asc","status:desc"],"type":"string"},{"type":"null"}],"title":"Sort"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Studies","tags":["studies"]},"post":{"description":"Create a study (FR-1 + AC-1) and enqueue the orchestrator job.","operationId":"create_study_api_v1_studies_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateStudyRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Study","tags":["studies"]}},"/api/v1/studies/chains/recent":{"get":{"description":"List recently-completed overnight chains (FR-1, AC-1/2/3/4/5/6/11/12).\n\nReturns the deduplicated set of completed overnight chains (length\n>= 2) ordered newest-tail-completion-first, capped at ``limit``. The\n``since`` filter restricts to chains whose tail completed at or\nafter the cutoff (used by the card to seed the \"what's new since I\nlast visited\" query).\n\nMalformed ``since`` / out-of-range ``limit`` flow through the\nglobal ``validation_exception_handler`` and return the canonical\n422 ``VALIDATION_ERROR`` envelope (no manual parse path).\n\nPagination: inert. ``next_cursor=null`` and ``has_more=false``\nalways — OQ-2 resolved limit-cap-only for v1. Keyset pagination\ndeferred to a separate ``chore_`` idea filed against the spec's\nopen questions.","operationId":"get_recent_chains_api_v1_studies_chains_recent_get","parameters":[{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"limit","required":false,"schema":{"default":20,"maximum":50,"minimum":1,"title":"Limit","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecentChainsResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Recent Chains","tags":["studies"]}},"/api/v1/studies/{study_id}":{"get":{"description":"Return a study by id (includes ``trials_summary``).","operationId":"get_study_detail_api_v1_studies__study_id__get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Study Detail","tags":["studies"]}},"/api/v1/studies/{study_id}/cancel":{"post":{"description":"Cancel a study (Story 2.3, FR-8 + AC-8/AC-9).\n\nOptionally cascades to in-flight chain children.\n\n``?cascade=true`` (default): routes through\n:func:`services.study_state.cancel_study_with_chain_cascade` —\ncancels the parent (if in-flight) AND recursively cancels in-flight\ndescendants. Tolerates terminal parents (recurses through completed\nintermediates to reach an in-flight grandchild).\n\n``?cascade=false``: routes through the original\n:func:`services.study_state.cancel_study` — single-study cancel,\npreserves the existing 409 error contract on terminal parents\n(AC-9 wire contract).","operationId":"cancel_study_api_v1_studies__study_id__cancel_post","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}},{"in":"query","name":"cascade","required":false,"schema":{"default":"true","title":"Cascade","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyDetail"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Cancel Study","tags":["studies"]}},"/api/v1/studies/{study_id}/chain":{"get":{"description":"Return the rolled-up chain summary for the study and its lineage (FR-3).\n\nWalks to the chain anchor, aggregates the completed-link subset into a\nbest link + cumulative lift + derived stop reason, and emits per-link\ndeltas. The anchor's ``delta_from_prev`` is always ``None`` (spec §8.3).\nReturns ``404 STUDY_NOT_FOUND`` when the study does not exist.","operationId":"get_study_chain_api_v1_studies__study_id__chain_get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyChainResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Study Chain","tags":["studies"]}},"/api/v1/studies/{study_id}/children":{"get":{"description":"List direct child studies of a parent (FR-10 + D-13).\n\nReturns ``{\"data\": [], \"next_cursor\": null}`` for a study with no\nchildren — empty data array, NOT 404. 404 only fires when the parent\nstudy itself is missing.\n\nPer D-13 (direct-children-only): does NOT return transitive\ndescendants. The chain panel renders parent ↑ + direct children ↓;\noperators walk lineage one hop per page navigation.","operationId":"list_study_children_api_v1_studies__study_id__children_get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudyListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Study Children","tags":["studies"]}},"/api/v1/studies/{study_id}/digest":{"get":{"description":"Fetch the digest for a completed study.\n\nReturns 404 ``DIGEST_NOT_READY`` (``retryable=true``) when:\n- the study is not in ``status='completed'``, OR\n- the study is completed but the worker hasn't written the digest yet\n (worker lag, or a worker-side terminal failure like\n ``OPENAI_NOT_CONFIGURED`` deferred the run).","operationId":"get_study_digest_api_v1_studies__study_id__digest_get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DigestResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Get Study Digest","tags":["digests"]}},"/api/v1/studies/{study_id}/trials":{"get":{"description":"List trials in a study (FR-6).\n\nSort variants per spec §7.4: ``primary_metric_desc`` (default),\n``primary_metric_asc``, ``ended_at_desc``, ``ended_at_asc``,\n``optuna_trial_number_asc``.","operationId":"list_study_trials_api_v1_studies__study_id__trials_get","parameters":[{"in":"path","name":"study_id","required":true,"schema":{"title":"Study Id","type":"string"}},{"in":"query","name":"cursor","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"title":"Limit","type":"integer"}},{"in":"query","name":"since","required":false,"schema":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Since"}},{"in":"query","name":"sort","required":false,"schema":{"default":"primary_metric_desc","title":"Sort","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrialListResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"List Study Trials","tags":["trials"]}},"/healthz":{"get":{"description":"Probe each subsystem in parallel and return the documented JSON shape.\n\nArgs:\n settings: Application settings (DB URL, ES/OS URLs, OpenAI base URL, etc.)\n redis_client: Redis client for ping probe + capability-cache read\n es_client: shared httpx client for ES + OpenSearch HTTP probes\n db: Async DB session for the registered-clusters aggregate (Story 3.5)\n\nReturns:\n JSONResponse with the HealthResponse body and HTTP 200 (healthy) or 503 (degraded).","operationId":"healthz_healthz_get","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}},"description":"Successful Response"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}},"description":"One or more required subsystems is down"}},"summary":"Healthz","tags":["operator"]}},"/webhooks/github":{"post":{"description":"Receive a single GitHub webhook delivery.\n\nReturns ``{\"status\": \"ok\", \"action\": }`` where\n``wire_action`` is one of the four values in\n:data:`WEBHOOK_ACTION_VALUES`.\n\nRaises:\n HTTPException(403, INVALID_SIGNATURE): bad signature or unknown\n repository. Both share one error code so the receiver does\n not reveal repo enumeration.","operationId":"github_webhook_webhooks_github_post","responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"title":"Response Github Webhook Webhooks Github Post","type":"object"}}},"description":"Successful Response"}},"summary":"Github Webhook","tags":["webhooks"]}}}} diff --git a/ui/src/__tests__/lib/enums-proposal-status-discipline.test.ts b/ui/src/__tests__/lib/enums-proposal-status-discipline.test.ts new file mode 100644 index 00000000..bd8824fa --- /dev/null +++ b/ui/src/__tests__/lib/enums-proposal-status-discipline.test.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Phase 3 Story 4.1 — PROPOSAL_STATUS_VALUES enum value-lock (AC-18). + * + * Mirrors the discipline established by `enums-convergence-discipline.test.ts`: + * the canonical source-of-truth is + * `backend/app/api/v1/schemas.py ProposalStatusWire`. Drift on either side + * trips this test or the backend's contract test on the same Literal. + * + * `superseded` was added in Phase 3 alongside the migration that extends + * the `proposals_status_check` CHECK constraint. + */ + +import { describe, expect, it } from 'vitest'; + +import { PROPOSAL_STATUS_VALUES, type ProposalStatus } from '@/lib/enums'; + +describe('PROPOSAL_STATUS_VALUES', () => { + it('contains exactly the five statuses in canonical order', () => { + expect(PROPOSAL_STATUS_VALUES.length).toBe(5); + expect(PROPOSAL_STATUS_VALUES).toEqual([ + 'pending', + 'pr_opened', + 'pr_merged', + 'rejected', + 'superseded', + ]); + }); + + it('type alias narrows to the union of canonical values', () => { + const status: ProposalStatus = 'superseded'; + // Compile-time check; the assertion keeps the test runtime green. + expect(status).toBe('superseded'); + }); +}); diff --git a/ui/src/__tests__/lib/enums.test.ts b/ui/src/__tests__/lib/enums.test.ts index cb64618c..45dad6d5 100644 --- a/ui/src/__tests__/lib/enums.test.ts +++ b/ui/src/__tests__/lib/enums.test.ts @@ -88,7 +88,7 @@ describe('wire-value arrays match documented spec table', () => { [ 'PROPOSAL_STATUS_VALUES', PROPOSAL_STATUS_VALUES, - ['pending', 'pr_opened', 'pr_merged', 'rejected'], + ['pending', 'pr_opened', 'pr_merged', 'rejected', 'superseded'], ], ['PROPOSAL_PR_STATE_VALUES', PROPOSAL_PR_STATE_VALUES, ['open', 'closed', 'merged']], ['CONFIG_REPO_PROVIDER_VALUES', CONFIG_REPO_PROVIDER_VALUES, ['github']], diff --git a/ui/src/app/proposals/[id]/page.tsx b/ui/src/app/proposals/[id]/page.tsx index 944a6332..8ebabdd0 100644 --- a/ui/src/app/proposals/[id]/page.tsx +++ b/ui/src/app/proposals/[id]/page.tsx @@ -15,6 +15,7 @@ import { CurrentlyLiveBadge } from '@/components/proposals/currently-live-badge' import { FullParamSpacePanel } from '@/components/proposals/full-param-space-panel'; import { PrPanel } from '@/components/proposals/pr-panel'; import { ProposalHeader } from '@/components/proposals/proposal-header'; +import { ReinstateProposalButton } from '@/components/proposals/reinstate-proposal-button'; import { RejectDialog } from '@/components/proposals/reject-dialog'; import { SuggestedFollowupsPanel } from '@/components/proposals/suggested-followups-panel'; import { CreateStudyModal, type PrefillValues } from '@/components/studies/create-study-modal'; @@ -380,6 +381,11 @@ export function ProposalDetailView({ proposalId }: { proposalId: string }) { )} + {proposal.status === 'superseded' && ( +
+ +
+ )} {proposal.digest?.suggested_followups && proposal.digest.suggested_followups.length > 0 && ( diff --git a/ui/src/app/proposals/page.tsx b/ui/src/app/proposals/page.tsx index 89fdc52e..f71261a1 100644 --- a/ui/src/app/proposals/page.tsx +++ b/ui/src/app/proposals/page.tsx @@ -8,6 +8,7 @@ import { Suspense } from 'react'; import { CurrentlyLiveFilterChip } from '@/components/proposals/currently-live-filter-chip'; import { ProposalsTable } from '@/components/proposals/proposals-table'; import { proposalsColumns } from '@/components/proposals/proposals-table.column-config'; +import { ShowSupersededFilterChip } from '@/components/proposals/show-superseded-filter-chip'; import { Card, CardContent } from '@/components/ui/card'; import { useDataTableUrlState } from '@/hooks/use-data-table-url-state'; import { useProposals } from '@/lib/api/proposals'; @@ -35,6 +36,11 @@ function ProposalsPageInner() { // stays API-only per spec §19 decision-log). const isLastMergedActive = urlState.filters['is_last_merged'] === 'true'; + // Phase 3 D-15 revised: ``?include_superseded=true`` opts the list + // into surfacing superseded rows. Default URL omits the param; the + // backend's implicit-exclusion default keeps superseded rows hidden. + const includeSupersededActive = urlState.filters['include_superseded'] === 'true'; + const query = useProposals( { status, @@ -45,6 +51,7 @@ function ProposalsPageInner() { sort: urlState.sort ?? undefined, cursor: urlState.cursor ?? undefined, limit: urlState.pageSize, + include_superseded: includeSupersededActive ? true : undefined, }, { // FR-1: 30s refetch when any row has status='pr_opened' AND pr_state='open' @@ -60,10 +67,20 @@ function ProposalsPageInner() {

Proposals

- urlState.setFilter('is_last_merged', isLastMergedActive ? null : 'true')} - /> +
+ + urlState.setFilter('include_superseded', includeSupersededActive ? null : 'true') + } + /> + + urlState.setFilter('is_last_merged', isLastMergedActive ? null : 'true') + } + /> +
diff --git a/ui/src/components/common/status-badge.tsx b/ui/src/components/common/status-badge.tsx index 5afd3bf8..c4c916c8 100644 --- a/ui/src/components/common/status-badge.tsx +++ b/ui/src/components/common/status-badge.tsx @@ -25,6 +25,10 @@ const VARIANT_TABLE: Record> = { pr_opened: 'default', pr_merged: 'success', rejected: 'outline', + // Phase 3 D-12: reuses the `outline` variant; visual distinction + // from `rejected` comes from the badge label text + the row's + // lower visual weight when the "Show superseded" toggle surfaces it. + superseded: 'outline', }, proposal_pr: { open: 'default', diff --git a/ui/src/components/proposals/proposal-header.tsx b/ui/src/components/proposals/proposal-header.tsx index 6a0ab982..57e0c3c1 100644 --- a/ui/src/components/proposals/proposal-header.tsx +++ b/ui/src/components/proposals/proposal-header.tsx @@ -17,6 +17,7 @@ const STATUS_TO_GLOSSARY_KEY = { pr_opened: 'proposal.status.pr_opened', pr_merged: 'proposal.status.pr_merged', rejected: 'proposal.status.rejected', + superseded: 'proposal.status.superseded', } as const satisfies Record; const PR_STATE_TO_GLOSSARY_KEY = { diff --git a/ui/src/components/proposals/reinstate-proposal-button.tsx b/ui/src/components/proposals/reinstate-proposal-button.tsx new file mode 100644 index 00000000..f1b26f84 --- /dev/null +++ b/ui/src/components/proposals/reinstate-proposal-button.tsx @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { toast } from 'sonner'; + +import { InfoTooltip } from '@/components/common/info-tooltip'; +import { Button } from '@/components/ui/button'; +import { type ProposalDetail, useReinstateProposal } from '@/lib/api/proposals'; + +export interface ReinstateProposalButtonProps { + proposal: ProposalDetail; +} + +/** + * Phase 3 FR-8: "Reinstate" button on `/proposals/[id]`. Visible only + * when `proposal.status === 'superseded'`. On click, POSTs to + * `/api/v1/proposals/{id}/reinstate` and flips the row back to pending. + * + * Mirrors the {@link import('./reject-dialog').RejectDialog} placement + * pattern — sits to the right of the PR panel as a sibling action. + * Backend reuses the existing `INVALID_STATE_TRANSITION` error code + * (D-16); the toast text below handles both the stale-cache 409 and + * the unknown-id 404 paths uniformly. + */ +export function ReinstateProposalButton({ proposal }: ReinstateProposalButtonProps) { + const reinstate = useReinstateProposal(); + const handleClick = () => { + reinstate.mutate(proposal.id, { + onSuccess: () => { + toast.success('Proposal reinstated.'); + }, + onError: (err) => { + if (err.errorCode === 'INVALID_STATE_TRANSITION') { + toast.error('This proposal is no longer superseded — refreshing.'); + } else if (err.errorCode === 'PROPOSAL_NOT_FOUND') { + toast.error('Proposal no longer exists.'); + } else { + toast.error(`Reinstate failed: ${err.message}`); + } + }, + }); + }; + return ( + + + + + ); +} diff --git a/ui/src/components/proposals/show-superseded-filter-chip.tsx b/ui/src/components/proposals/show-superseded-filter-chip.tsx new file mode 100644 index 00000000..432a4241 --- /dev/null +++ b/ui/src/components/proposals/show-superseded-filter-chip.tsx @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { InfoTooltip } from '@/components/common/info-tooltip'; + +interface ShowSupersededFilterChipProps { + isActive: boolean; + onToggle: () => void; +} + +/** + * Two-state filter chip wired to the proposals-page URL state + * (`?include_superseded=true`). Off state has no URL param — the + * backend default (Phase 3 D-15 revised) omits superseded rows when + * neither `?status=` nor `?include_superseded` is set. + * + * Mirrors the {@link import('./currently-live-filter-chip').CurrentlyLiveFilterChip} + * shape so the two chips stack visually consistently on the proposals + * page. + */ +export function ShowSupersededFilterChip({ isActive, onToggle }: ShowSupersededFilterChipProps) { + return ( + + + + + ); +} diff --git a/ui/src/lib/api/proposals.ts b/ui/src/lib/api/proposals.ts index 795c8b42..be649fd1 100644 --- a/ui/src/lib/api/proposals.ts +++ b/ui/src/lib/api/proposals.ts @@ -39,6 +39,10 @@ export interface ProposalsFilter { sort?: string | undefined; cursor?: string | undefined; limit?: number | undefined; + // Phase 3 D-15 revised: backend default omits ``superseded`` rows when + // ``?status=`` is unset. Set this to ``true`` (the "Show superseded" + // chip on /proposals) to surface them. + include_superseded?: boolean | undefined; } type RefetchInterval = @@ -54,8 +58,18 @@ export function useProposals( filter: ProposalsFilter = {}, options: UseProposalsOptions = {}, ): UseQueryResult { - const { status, cluster_id, study_id, template_id, source, is_last_merged, sort, cursor, limit } = - filter; + const { + status, + cluster_id, + study_id, + template_id, + source, + is_last_merged, + sort, + cursor, + limit, + include_superseded, + } = filter; return useQuery({ queryKey: [ 'proposals', @@ -69,6 +83,7 @@ export function useProposals( sort, cursor, limit, + include_superseded, }, ], queryFn: async () => { @@ -83,6 +98,9 @@ export function useProposals( sort, cursor, limit, + // Phase 3 D-15 revised: only send the flag when it's true so + // the default URL stays untouched. + ...(include_superseded ? { include_superseded: true } : {}), }, }); return { ...data, totalCount: Number(headers.get('X-Total-Count') ?? 0) }; @@ -167,3 +185,25 @@ export function useRejectProposal(): UseMutationResult< }, }); } + +/** + * Phase 3 FR-6: ``superseded → pending`` flip. Backend reuses the same + * 404 / 409 error codes as ``reject_proposal`` (D-16) — the message + * field disambiguates which transition is wrong. + */ +export function useReinstateProposal(): UseMutationResult { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (proposalId) => { + const { data } = await apiClient.post( + `/api/v1/proposals/${proposalId}/reinstate`, + {}, + ); + return data; + }, + onSettled: (_data, _err, proposalId) => { + qc.invalidateQueries({ queryKey: ['proposal', proposalId] }); + qc.invalidateQueries({ queryKey: ['proposals'] }); + }, + }); +} diff --git a/ui/src/lib/enums.ts b/ui/src/lib/enums.ts index 557c494a..e9ba286b 100644 --- a/ui/src/lib/enums.ts +++ b/ui/src/lib/enums.ts @@ -200,7 +200,13 @@ export const RATING_VALUES = [0, 1, 2, 3] as const; export type Rating = (typeof RATING_VALUES)[number]; // Values must match backend/app/api/v1/schemas.py ProposalStatusWire. -export const PROPOSAL_STATUS_VALUES = ['pending', 'pr_opened', 'pr_merged', 'rejected'] as const; +export const PROPOSAL_STATUS_VALUES = [ + 'pending', + 'pr_opened', + 'pr_merged', + 'rejected', + 'superseded', +] as const; export type ProposalStatus = (typeof PROPOSAL_STATUS_VALUES)[number]; // Values must match backend/app/api/v1/schemas.py ProposalPrStateWire. diff --git a/ui/src/lib/glossary.ts b/ui/src/lib/glossary.ts index f6ff0255..1c6dc303 100644 --- a/ui/src/lib/glossary.ts +++ b/ui/src/lib/glossary.ts @@ -572,6 +572,17 @@ export const glossary = { 'proposal.status.rejected': { short: 'Rejected by an operator. See "Rejected reason" for context; no PR will be opened.', }, + 'proposal.status.superseded': { + short: + 'A non-winning sibling from an overnight chain. Preserved for audit; reinstate to ship it instead.', + }, + 'proposal.reinstate': { + short: + "Flip this superseded proposal back to pending so you can ship it — when the chain's pick doesn't match your judgment.", + }, + 'proposal.show_superseded_filter': { + short: 'Show non-winning chain-link proposals. Hidden by default to focus on actionable rows.', + }, // Source-of-truth: backend/app/api/v1/schemas.py ProposalPrStateWire // (mirrored in ui/src/lib/enums.ts PROPOSAL_PR_STATE_VALUES). diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 3dd484b8..4fa17dc2 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -865,6 +865,11 @@ export interface paths { * study-detail page's pending-proposal lookup). Both reject invalid * UUIDs with 422 via FastAPI's UUID parsing. ``?sort=`` (Story 1.3) is * a :data:`ProposalSortKey` value with sort-aware cursor. + * + * Phase 3 D-15 revised: ``?include_superseded`` defaults to ``False``; + * when ``False`` AND no ``?status=`` is set, the response omits + * ``superseded`` rows. Explicit ``?status=`` always beats implicit + * ``include_superseded`` (single-value backward compat preserved). */ get: operations["list_proposals_endpoint_api_v1_proposals_get"]; put?: never; @@ -924,6 +929,31 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/proposals/{proposal_id}/reinstate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reinstate Proposal Endpoint + * @description Phase 3 FR-6: ``superseded → pending`` transition. + * + * Mirrors :func:`reject_proposal_endpoint` (D-17 — read-check-mutate so + * 404 vs 409 stays deterministic). Reuses ``INVALID_STATE_TRANSITION`` + * per D-16; emits ``chain_proposal_reinstated`` structlog AFTER commit + * per D-19. + */ + post: operations["reinstate_proposal_endpoint_api_v1_proposals__proposal_id__reinstate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/proposals/{proposal_id}/reject": { parameters: { query?: never; @@ -2684,7 +2714,7 @@ export interface components { * Status * @enum {string} */ - status: "pending" | "pr_opened" | "pr_merged" | "rejected"; + status: "pending" | "pr_opened" | "pr_merged" | "rejected" | "superseded"; /** Study Id */ study_id: string | null; study_summary: components["schemas"]["_StudySummary"] | null; @@ -2722,7 +2752,7 @@ export interface components { * Status * @enum {string} */ - status: "pending" | "pr_opened" | "pr_merged" | "rejected"; + status: "pending" | "pr_opened" | "pr_merged" | "rejected" | "superseded"; /** Study Id */ study_id: string | null; template: components["schemas"]["_TemplateEmbed"]; @@ -5162,7 +5192,7 @@ export interface operations { list_proposals_endpoint_api_v1_proposals_get: { parameters: { query?: { - status?: ("pending" | "pr_opened" | "pr_merged" | "rejected") | null; + status?: ("pending" | "pr_opened" | "pr_merged" | "rejected" | "superseded") | null; cluster_id?: string | null; source?: ("study" | "manual") | null; template_id?: string | null; @@ -5171,6 +5201,7 @@ export interface operations { cursor?: string | null; limit?: number; sort?: ("created_at:asc" | "created_at:desc" | "status:asc" | "status:desc" | "pr_state:asc" | "pr_state:desc") | null; + include_superseded?: boolean; }; header?: never; path?: never; @@ -5293,6 +5324,37 @@ export interface operations { }; }; }; + reinstate_proposal_endpoint_api_v1_proposals__proposal_id__reinstate_post: { + parameters: { + query?: never; + header?: never; + path: { + proposal_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProposalDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; reject_proposal_endpoint_api_v1_proposals__proposal_id__reject_post: { parameters: { query?: never; From 07907dc435e2203049ef6b2e88c5ed28472e77dd Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Thu, 4 Jun 2026 23:12:00 -0400 Subject: [PATCH 5/7] docs(phase3): commit spec + plan + pipeline_status + preflighted idea (+ dashboard regen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat_overnight_final_solution_phase3 planning artifacts + auto-regenerated dashboards. Cross-model: 1 GPT-5.5 cycle on the spec; plan-gen Pass 1+2 caught 2 codebase-accuracy fixes. See feature_spec.md §19 decision log for full decisions (D-1 through D-20). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: SoundMindsAI --- docs/00_overview/DASHBOARD.md | 2 +- docs/00_overview/MVP2_DASHBOARD.md | 68 +- docs/00_overview/dashboard.html | 2 +- docs/00_overview/mvp2_dashboard.html | 44 +- .../feature_spec.md | 672 +++++++++++++ .../idea.md | 22 +- .../implementation_plan.md | 911 ++++++++++++++++++ .../pipeline_status.md | 23 + 8 files changed, 1677 insertions(+), 67 deletions(-) create mode 100644 docs/00_overview/planned_features/02_mvp2/feat_overnight_final_solution_phase3/feature_spec.md create mode 100644 docs/00_overview/planned_features/02_mvp2/feat_overnight_final_solution_phase3/implementation_plan.md create mode 100644 docs/00_overview/planned_features/02_mvp2/feat_overnight_final_solution_phase3/pipeline_status.md diff --git a/docs/00_overview/DASHBOARD.md b/docs/00_overview/DASHBOARD.md index 0c7067c3..01f8bc1c 100644 --- a/docs/00_overview/DASHBOARD.md +++ b/docs/00_overview/DASHBOARD.md @@ -7,7 +7,7 @@ _Top-level index across MVP1 → GA v1+ as of **2026-06-05**. Click a release na | Release | Theme | Progress | Status | |---|---|---|---| | [MVP1 / v0.1](MVP1_DASHBOARD.md) | The Loop | 95 / 95 scoped done | **Complete** | -| [MVP2 / v0.2](MVP2_DASHBOARD.md) | Three-Engine + Real Signals | 19 / 28 scoped done · 25 remaining | **In progress** | +| [MVP2 / v0.2](MVP2_DASHBOARD.md) | Three-Engine + Real Signals | 19 / 29 scoped done · 26 remaining | **In progress** | | MVP3 / v0.3 | Observable | — | **Not yet scoped** | | GA v1 / v1.0 | Production-ready | — | **Not yet scoped** | diff --git a/docs/00_overview/MVP2_DASHBOARD.md b/docs/00_overview/MVP2_DASHBOARD.md index 73f45d0a..289a7c62 100644 --- a/docs/00_overview/MVP2_DASHBOARD.md +++ b/docs/00_overview/MVP2_DASHBOARD.md @@ -21,15 +21,15 @@ Plan approved; run /impl-execute to ship | Metric | Value | |---|---| | Filed under MVP2 | **50** folders total (done + specced not-done + idea backlog + bugs) | -| Specced features done | **19 / 28** (68%) — of features *past the idea stage* (those with a spec); the idea backlog below is NOT in this denominator, so 100% ≠ release complete | +| Specced features done | **19 / 29** (66%) — of features *past the idea stage* (those with a spec); the idea backlog below is NOT in this denominator, so 100% ≠ release complete | | Pending work | **29** items (every not-done feat/infra/chore/bug across all priorities) | | → P0 — do next | **0** unblocking / paying daily cost | | → P1 | **0** high-value, ready when P0 clears | | → P2 (default) | 25 important to file, not blocking | | → Backlog | 4 captured for record, not planned | | Open bugs | 9 | -| Legacy "Path to MVP2" | 25 items — scoped-not-done + bugs + chore-ideas only (excludes feat/infra ideas) | -| Backlog ideas | 4 idea-only feat/infra (not yet scoped into MVP2) | +| Legacy "Path to MVP2" | 26 items — scoped-not-done + bugs + chore-ideas only (excludes feat/infra ideas) | +| Backlog ideas | 3 idea-only feat/infra (not yet scoped into MVP2) | | In flight | 0 feature(s) actively shipping | ## Pipeline @@ -64,48 +64,48 @@ Plan approved; run /impl-execute to ship _None._ -### Plan (11) +### Plan (12) | # | Priority | Feature | Type | One-liner | Depends on | Status | |---|---|---|---|---|---|---| | 1 | P2 | [feat_apply_path_normalizer_declaration](planned_features/02_mvp2/feat_apply_path_normalizer_declaration/feature_spec.md) | Feature | The winning normalizer ships as a **structured, language-agnostic manifest** in the config-repo PR — not just prose. | — | — | -| 2 | P2 | [feat_query_normalization_tuning](planned_features/02_mvp2/feat_query_normalization_tuning/feature_spec.md) | Feature | A template that opts in by declaring `query_normalizer` as a Categorical param gets the Optuna loop deciding empirically — on the operator's judgment set — whether lowercasing, trimming, or contractio | — | — | -| 3 | P2 | [feat_query_normalizer_typed_pipeline](planned_features/02_mvp2/feat_query_normalizer_typed_pipeline/feature_spec.md) | Feature | A new typed search-space member `NormalizerPipelineParam` lets a template declare an **ordered list of normalization steps**; the Optuna loop samples over the powerset of declared steps and proposes t | — | — | -| 4 | P2 | [feat_ubi_llm_study_comparison](planned_features/02_mvp2/feat_ubi_llm_study_comparison/feature_spec.md) | Feature | A single dedicated route `/studies/compare?a={id}&b={id}` renders the two studies side-by-side with a per-panel diff column: a sentence-level digest-narrative diff, a best-trial parameter table with s | — | [PR #320](https://github.com/SoundMindsAI/relyloop/pull/320) | -| 5 | P2 | [chore_arq_pool_aclose_deprecation](planned_features/02_mvp2/chore_arq_pool_aclose_deprecation/feature_spec.md) | Chore | Both call sites use `await arq_pool.aclose()`; no `DeprecationWarning` on shutdown; a regression guard asserts the async-correct form on both paths so a future edit cannot silently reintroduce `close( | — | — | -| 6 | P2 | [chore_cluster_detail_rung_badge](planned_features/02_mvp2/chore_cluster_detail_rung_badge/feature_spec.md) | Chore | The cluster-detail page surfaces a `` for the cluster, scoped by a user-selected (or auto-seeded) query set + target. | — | [PR #320](https://github.com/SoundMindsAI/relyloop/pull/320) | -| 7 | P2 | [chore_demo_seeding_integration_tests_rewrite](planned_features/02_mvp2/chore_demo_seeding_integration_tests_rewrite/feature_spec.md) | Chore | The 9 skipped cases are rewritten to the async "POST + poll-until-terminal" shape, the timeout case is re-homed to the worker layer, a new `AC-Async` case asserts the `running → complete` polling tran | — | [PR #286](https://github.com/SoundMindsAI/relyloop/pull/286) | -| 8 | P2 | [chore_studies_post_arq_spy_fixture](planned_features/02_mvp2/chore_studies_post_arq_spy_fixture/feature_spec.md) | Chore | A reusable `arq_pool_spy` integration fixture that records every `enqueue_job(name, *args)` call, letting studies-POST tests positively assert `spy.calls == []` on rejection and `spy.calls == [("start | — | — | -| 9 | P2 | [chore_ubi_reader_search_after_pagination](planned_features/02_mvp2/chore_ubi_reader_search_after_pagination/feature_spec.md) | Chore | A new engine-neutral `SearchAdapter.scan_all` cursor-scan lets `UbiReader` iterate the **entire** matching event/query stream for a window (subject to a caller ceiling), folding each page into the agg | — | [PR #413](https://github.com/SoundMindsAI/relyloop/pull/413) | -| 10 | P2 | [bug_baseline_phase_test_isolation](planned_features/02_mvp2/bug_baseline_phase_test_isolation/feature_spec.md) | Bug | The three `TestComputeBaselineWaitS` cases pass standalone — `.venv/bin/python -m pytest backend/tests/unit/workers/test_orchestrator_baseline_phase.py -p no:randomly` is all-green with no reliance on | — | — | -| 11 | P2 | [bug_judgment_header_omits_click_bucket](planned_features/02_mvp2/bug_judgment_header_omits_click_bucket/feature_spec.md) | Bug | The header renders all three buckets (`llm`, `human`, `click`) so the displayed terms sum to the displayed total count, making the doc-comment claim ("the UI's source-breakdown card now renders all th | — | — | +| 2 | P2 | [feat_overnight_final_solution_phase3](planned_features/02_mvp2/feat_overnight_final_solution_phase3/feature_spec.md) | Feature | Non-winning chain links' proposals transition `pending → superseded` when the chain terminates. | — | [PR #440](https://github.com/SoundMindsAI/relyloop/pull/440) merged 2026-06-04 | +| 3 | P2 | [feat_query_normalization_tuning](planned_features/02_mvp2/feat_query_normalization_tuning/feature_spec.md) | Feature | A template that opts in by declaring `query_normalizer` as a Categorical param gets the Optuna loop deciding empirically — on the operator's judgment set — whether lowercasing, trimming, or contractio | — | — | +| 4 | P2 | [feat_query_normalizer_typed_pipeline](planned_features/02_mvp2/feat_query_normalizer_typed_pipeline/feature_spec.md) | Feature | A new typed search-space member `NormalizerPipelineParam` lets a template declare an **ordered list of normalization steps**; the Optuna loop samples over the powerset of declared steps and proposes t | — | — | +| 5 | P2 | [feat_ubi_llm_study_comparison](planned_features/02_mvp2/feat_ubi_llm_study_comparison/feature_spec.md) | Feature | A single dedicated route `/studies/compare?a={id}&b={id}` renders the two studies side-by-side with a per-panel diff column: a sentence-level digest-narrative diff, a best-trial parameter table with s | — | [PR #320](https://github.com/SoundMindsAI/relyloop/pull/320) | +| 6 | P2 | [chore_arq_pool_aclose_deprecation](planned_features/02_mvp2/chore_arq_pool_aclose_deprecation/feature_spec.md) | Chore | Both call sites use `await arq_pool.aclose()`; no `DeprecationWarning` on shutdown; a regression guard asserts the async-correct form on both paths so a future edit cannot silently reintroduce `close( | — | — | +| 7 | P2 | [chore_cluster_detail_rung_badge](planned_features/02_mvp2/chore_cluster_detail_rung_badge/feature_spec.md) | Chore | The cluster-detail page surfaces a `` for the cluster, scoped by a user-selected (or auto-seeded) query set + target. | — | [PR #320](https://github.com/SoundMindsAI/relyloop/pull/320) | +| 8 | P2 | [chore_demo_seeding_integration_tests_rewrite](planned_features/02_mvp2/chore_demo_seeding_integration_tests_rewrite/feature_spec.md) | Chore | The 9 skipped cases are rewritten to the async "POST + poll-until-terminal" shape, the timeout case is re-homed to the worker layer, a new `AC-Async` case asserts the `running → complete` polling tran | — | [PR #286](https://github.com/SoundMindsAI/relyloop/pull/286) | +| 9 | P2 | [chore_studies_post_arq_spy_fixture](planned_features/02_mvp2/chore_studies_post_arq_spy_fixture/feature_spec.md) | Chore | A reusable `arq_pool_spy` integration fixture that records every `enqueue_job(name, *args)` call, letting studies-POST tests positively assert `spy.calls == []` on rejection and `spy.calls == [("start | — | — | +| 10 | P2 | [chore_ubi_reader_search_after_pagination](planned_features/02_mvp2/chore_ubi_reader_search_after_pagination/feature_spec.md) | Chore | A new engine-neutral `SearchAdapter.scan_all` cursor-scan lets `UbiReader` iterate the **entire** matching event/query stream for a window (subject to a caller ceiling), folding each page into the agg | — | [PR #413](https://github.com/SoundMindsAI/relyloop/pull/413) | +| 11 | P2 | [bug_baseline_phase_test_isolation](planned_features/02_mvp2/bug_baseline_phase_test_isolation/feature_spec.md) | Bug | The three `TestComputeBaselineWaitS` cases pass standalone — `.venv/bin/python -m pytest backend/tests/unit/workers/test_orchestrator_baseline_phase.py -p no:randomly` is all-green with no reliance on | — | — | +| 12 | P2 | [bug_judgment_header_omits_click_bucket](planned_features/02_mvp2/bug_judgment_header_omits_click_bucket/feature_spec.md) | Bug | The header renders all three buckets (`llm`, `human`, `click`) so the displayed terms sum to the displayed total count, making the doc-comment claim ("the UI's source-breakdown card now renders all th | — | — | ### Spec (0) _None._ -### Idea (18) +### Idea (17) | # | Priority | Feature | Type | One-liner | Depends on | Status | |---|---|---|---|---|---|---| -| 1 | P2 | [feat_overnight_final_solution_phase3](planned_features/02_mvp2/feat_overnight_final_solution_phase3/idea.md) | Feature | When `follow_suggestions` runs a 4-link chain, today's proposal-creation logic at [`backend/workers/orchestrator.py:693-740`](../backend/workers/orchestrator.py#L693-L740)… | — | Idea — deferred Phase 3 from `feat_overnight_final_solution` Phase 1 spec | -| 2 | P2 | [infra_smoke_fork_pr_secret_skip](planned_features/02_mvp2/infra_smoke_fork_pr_secret_skip/idea.md) | Infra | `.github/workflows/pr.yml` triggers on `pull_request:` ([pr.yml:43](../.github/workflows/pr.yml)) — **not** `pull_request_target`. GitHub deliberately withholds repository secrets from workflows trigg | — | Idea — tangential discovery while merging PR #387 (`chore_arq_pool_aclose_deprecation`) | -| 3 | P2 | [chore_demo_reseed_partial_completion_fast_test](planned_features/02_mvp2/chore_demo_reseed_partial_completion_fast_test/idea.md) | Chore | `infra_solr_ci_readiness` made the demo reseed engine-tolerant: when an engine is unreachable, its scenario is skipped, the reseed completes with `status="complete"` and a non-empty `scenarios_skipped | — | Idea — tangential discovery during `infra_solr_ci_readiness` Story 1.2 implementation | -| 4 | P2 | [chore_e2e_overnight_strategy_radix_select_timing](planned_features/02_mvp2/chore_e2e_overnight_strategy_radix_select_timing/idea.md) | Chore | The Story 3.2 E2E spec walks the create-study wizard to Step 5, clicks the depth `` becomes visible. In chromium against `pnpm dev`, t | — | Idea — tangential follow-up captured during `feat_overnight_final_solution` Story 3.2 implementation | -| 5 | P2 | [chore_overnight_result_card_screenshot](planned_features/02_mvp2/chore_overnight_result_card_screenshot/idea.md) | Chore | The `docs/08_guides/tutorial-first-study.md` Step 12 sub-section *"In the morning — read the overnight result card"* shipped on PR #442 with prose only — no… | — | Idea — deferred FR-9 deliverable from PR #442 | -| 6 | P2 | [chore_pr_yml_parallelize_backend_job](planned_features/02_mvp2/chore_pr_yml_parallelize_backend_job/idea.md) | Chore | `.github/workflows/pr.yml` has a job named `backend (lint + typecheck + tests + coverage)` that runs four sequential things in one job: ruff/lint, mypy, the full pytest matrix (unit + integration + co | — | Idea — captured during PR #426 CI watch | -| 7 | P2 | [chore_solr_post_pipeline_followups](planned_features/02_mvp2/chore_solr_post_pipeline_followups/idea.md) | Chore | The 13-story `infra_adapter_solr` execution surfaced several follow-on items that fit neither the original spec nor any sister feature folder. None block the MVP2 Solr release — they're operator-exper | — | Idea — tangential observations from `infra_adapter_solr` end-to-end | -| 8 | P2 | [chore_ubi_hybrid_template_render](planned_features/02_mvp2/chore_ubi_hybrid_template_render/idea.md) | Chore | Idea — contract decision deferred (NOT a worker bug) | — | Idea — contract decision deferred (NOT a worker bug) | -| 9 | P2 | [bug_e2e_teardown_chain_node_delete_500](planned_features/02_mvp2/bug_e2e_teardown_chain_node_delete_500/idea.md) | Bug | The E2E global-teardown deletes seeded rows in a fixed order (per `chore_e2e_test_rows_isolation` Story 1.2 cleanup registration). For auto-followup **chains**, the seeded nodes are `queued` studies c | — | Idea — tangential discovery during `feat_overnight_autopilot` (Story 4.2 E2E, PR forthcoming) | -| 10 | P2 | [bug_relyloop_spec_ubi_section_drift](planned_features/02_mvp2/bug_relyloop_spec_ubi_section_drift/idea.md) | Bug | [`docs/00_overview/relyloop-spec.md`](relyloop-spec.md) §"Click-derived judgments — OpenSearch UBI as the engine-neutral primary path" (line ~706) carries two staleness bugs from the 2026-05-27 releas | — | Idea — captured during `feat_ubi_judgments` preflight (2026-05-29) | -| 11 | P2 | [bug_reseed_failure_blocks_retry_arq_singleton_dedup](planned_features/02_mvp2/bug_reseed_failure_blocks_retry_arq_singleton_dedup/idea.md) | Bug | `run_demo_reseed` is enqueued with a fixed Arq job id `demo_reseed:singleton` (the singleton concurrency guard). When a run reaches a terminal state, Arq stores its **result** under `arq:result:demo_r | — | Idea — tangential discovery while verifying `fix(demo): add Solr (8983) to the reseed engine host-URL mapping` (branch `feat_demo_reseed_solr_and_steplog`) | -| 12 | P2 | [bug_seed_meaningful_demos_silent_bulk_errors](planned_features/02_mvp2/bug_seed_meaningful_demos_silent_bulk_errors/idea.md) | Bug | [`scripts/seed_meaningful_demos.py:917-935`](../../scripts/seed_meaningful_demos.py#L917-L935) bulk-indexes 1000 Amazon ESCI products into a dedicated index per demo scenario: | — | Idea — captured during `bug_smoke_seed_es_unavailable_shards_race` Phase 2.5 tangential sweep | -| 13 | P2 | [bug_studies_detail_vitest_intermittent_timeout](planned_features/02_mvp2/bug_studies_detail_vitest_intermittent_timeout/idea.md) | Bug | Under the full `pnpm test` run (`vitest run`, default worker pool), the Study-detail-page render test sometimes blocks past the 5 s `testTimeout` default — but the test itself is data-driven from mock | — | Idea — captured during `chore_template_library_expansion` post-impl tangential sweep | -| 14 | P2 | [bug_webhook_concurrent_merge_race_timing_sensitive](planned_features/02_mvp2/bug_webhook_concurrent_merge_race_timing_sensitive/idea.md) | Bug | Idea — surfaced during `bug_demo_clusters_unreachable_in_healthz` PR #236 CI. | — | Idea — surfaced during `bug_demo_clusters_unreachable_in_healthz` PR #236 CI. | -| 15 | Backlog | [feat_fts_rank_ordering](planned_features/02_mvp2/feat_fts_rank_ordering/idea.md) | Feature | `feat_data_table_primitive` shipped filter-only FTS — `?q=foo` matches rows where `search_vector @@ plainto_tsquery('english', 'foo')` is true but orders results by `created_at DESC, id DESC` (the def | — | Idea — deferred from `feat_data_table_primitive` (MVP1) per spec §16. | -| 16 | Backlog | [infra_arq_subprocess_test](planned_features/02_mvp2/infra_arq_subprocess_test/idea.md) | Infra | Idea (deferred from `feat_study_lifecycle` Phase 2 / PR #25 final GPT-5.5 review). Still applicable as of 2026-05-14: the three in-process tests cited below still cover the resume contract correctly; | — | Idea (deferred from `feat_study_lifecycle` Phase 2 / PR #25 final GPT-5.5 review). Still applicable as of 2026-05-14: the three in-process tests cited below still cover the resume contract correctly; a subprocess test would add a narrow Arq-version-regression guard. | -| 17 | Backlog | [chore_auto_followup_parent_advisory_lock](planned_features/02_mvp2/chore_auto_followup_parent_advisory_lock/idea.md) | Chore | The shipped `feat_auto_followup_studies` worker uses a two-layer idempotency scheme: | — | Idea — captured as a standalone file to resolve broken cross-references in `feat_auto_followup_studies` D-11 + plan F2 + `bug_auto_followup_completed_parent_stop_chain_race/idea.md`. The slug was coined 2026-05-24 in D-11 but only existed as descriptive prose across other documents until now. | -| 18 | Backlog | [bug_chat_long_conversation_truncation](planned_features/02_mvp2/bug_chat_long_conversation_truncation/idea.md) | Bug | [`backend/app/services/agent_chat.send_user_message`](../../backend/app/services/agent_chat.py) defensively caps the OpenAI history at the most recent `HISTORY_MAX_MESSAGES = 100` messages… | — | Held for MVP2 (decided 2026-05-13). Folder renamed with `_mvp2` suffix to make the deferral visible at-a-glance in `ls docs/00_overview/planned_features/`. Resume work when MVP2 starts — no technical dependency on MVP2 infra (audit_log is N/A; Langfuse is convenience only); the deferral is scope discipline + zero current impact (latent bug, no operator has hit the 100-message cap). | +| 1 | P2 | [infra_smoke_fork_pr_secret_skip](planned_features/02_mvp2/infra_smoke_fork_pr_secret_skip/idea.md) | Infra | `.github/workflows/pr.yml` triggers on `pull_request:` ([pr.yml:43](../.github/workflows/pr.yml)) — **not** `pull_request_target`. GitHub deliberately withholds repository secrets from workflows trigg | — | Idea — tangential discovery while merging PR #387 (`chore_arq_pool_aclose_deprecation`) | +| 2 | P2 | [chore_demo_reseed_partial_completion_fast_test](planned_features/02_mvp2/chore_demo_reseed_partial_completion_fast_test/idea.md) | Chore | `infra_solr_ci_readiness` made the demo reseed engine-tolerant: when an engine is unreachable, its scenario is skipped, the reseed completes with `status="complete"` and a non-empty `scenarios_skipped | — | Idea — tangential discovery during `infra_solr_ci_readiness` Story 1.2 implementation | +| 3 | P2 | [chore_e2e_overnight_strategy_radix_select_timing](planned_features/02_mvp2/chore_e2e_overnight_strategy_radix_select_timing/idea.md) | Chore | The Story 3.2 E2E spec walks the create-study wizard to Step 5, clicks the depth `` becomes visible. In chromium against `pnpm dev`, t | — | Idea — tangential follow-up captured during `feat_overnight_final_solution` Story 3.2 implementation | +| 4 | P2 | [chore_overnight_result_card_screenshot](planned_features/02_mvp2/chore_overnight_result_card_screenshot/idea.md) | Chore | The `docs/08_guides/tutorial-first-study.md` Step 12 sub-section *"In the morning — read the overnight result card"* shipped on PR #442 with prose only — no… | — | Idea — deferred FR-9 deliverable from PR #442 | +| 5 | P2 | [chore_pr_yml_parallelize_backend_job](planned_features/02_mvp2/chore_pr_yml_parallelize_backend_job/idea.md) | Chore | `.github/workflows/pr.yml` has a job named `backend (lint + typecheck + tests + coverage)` that runs four sequential things in one job: ruff/lint, mypy, the full pytest matrix (unit + integration + co | — | Idea — captured during PR #426 CI watch | +| 6 | P2 | [chore_solr_post_pipeline_followups](planned_features/02_mvp2/chore_solr_post_pipeline_followups/idea.md) | Chore | The 13-story `infra_adapter_solr` execution surfaced several follow-on items that fit neither the original spec nor any sister feature folder. None block the MVP2 Solr release — they're operator-exper | — | Idea — tangential observations from `infra_adapter_solr` end-to-end | +| 7 | P2 | [chore_ubi_hybrid_template_render](planned_features/02_mvp2/chore_ubi_hybrid_template_render/idea.md) | Chore | Idea — contract decision deferred (NOT a worker bug) | — | Idea — contract decision deferred (NOT a worker bug) | +| 8 | P2 | [bug_e2e_teardown_chain_node_delete_500](planned_features/02_mvp2/bug_e2e_teardown_chain_node_delete_500/idea.md) | Bug | The E2E global-teardown deletes seeded rows in a fixed order (per `chore_e2e_test_rows_isolation` Story 1.2 cleanup registration). For auto-followup **chains**, the seeded nodes are `queued` studies c | — | Idea — tangential discovery during `feat_overnight_autopilot` (Story 4.2 E2E, PR forthcoming) | +| 9 | P2 | [bug_relyloop_spec_ubi_section_drift](planned_features/02_mvp2/bug_relyloop_spec_ubi_section_drift/idea.md) | Bug | [`docs/00_overview/relyloop-spec.md`](relyloop-spec.md) §"Click-derived judgments — OpenSearch UBI as the engine-neutral primary path" (line ~706) carries two staleness bugs from the 2026-05-27 releas | — | Idea — captured during `feat_ubi_judgments` preflight (2026-05-29) | +| 10 | P2 | [bug_reseed_failure_blocks_retry_arq_singleton_dedup](planned_features/02_mvp2/bug_reseed_failure_blocks_retry_arq_singleton_dedup/idea.md) | Bug | `run_demo_reseed` is enqueued with a fixed Arq job id `demo_reseed:singleton` (the singleton concurrency guard). When a run reaches a terminal state, Arq stores its **result** under `arq:result:demo_r | — | Idea — tangential discovery while verifying `fix(demo): add Solr (8983) to the reseed engine host-URL mapping` (branch `feat_demo_reseed_solr_and_steplog`) | +| 11 | P2 | [bug_seed_meaningful_demos_silent_bulk_errors](planned_features/02_mvp2/bug_seed_meaningful_demos_silent_bulk_errors/idea.md) | Bug | [`scripts/seed_meaningful_demos.py:917-935`](../../scripts/seed_meaningful_demos.py#L917-L935) bulk-indexes 1000 Amazon ESCI products into a dedicated index per demo scenario: | — | Idea — captured during `bug_smoke_seed_es_unavailable_shards_race` Phase 2.5 tangential sweep | +| 12 | P2 | [bug_studies_detail_vitest_intermittent_timeout](planned_features/02_mvp2/bug_studies_detail_vitest_intermittent_timeout/idea.md) | Bug | Under the full `pnpm test` run (`vitest run`, default worker pool), the Study-detail-page render test sometimes blocks past the 5 s `testTimeout` default — but the test itself is data-driven from mock | — | Idea — captured during `chore_template_library_expansion` post-impl tangential sweep | +| 13 | P2 | [bug_webhook_concurrent_merge_race_timing_sensitive](planned_features/02_mvp2/bug_webhook_concurrent_merge_race_timing_sensitive/idea.md) | Bug | Idea — surfaced during `bug_demo_clusters_unreachable_in_healthz` PR #236 CI. | — | Idea — surfaced during `bug_demo_clusters_unreachable_in_healthz` PR #236 CI. | +| 14 | Backlog | [feat_fts_rank_ordering](planned_features/02_mvp2/feat_fts_rank_ordering/idea.md) | Feature | `feat_data_table_primitive` shipped filter-only FTS — `?q=foo` matches rows where `search_vector @@ plainto_tsquery('english', 'foo')` is true but orders results by `created_at DESC, id DESC` (the def | — | Idea — deferred from `feat_data_table_primitive` (MVP1) per spec §16. | +| 15 | Backlog | [infra_arq_subprocess_test](planned_features/02_mvp2/infra_arq_subprocess_test/idea.md) | Infra | Idea (deferred from `feat_study_lifecycle` Phase 2 / PR #25 final GPT-5.5 review). Still applicable as of 2026-05-14: the three in-process tests cited below still cover the resume contract correctly; | — | Idea (deferred from `feat_study_lifecycle` Phase 2 / PR #25 final GPT-5.5 review). Still applicable as of 2026-05-14: the three in-process tests cited below still cover the resume contract correctly; a subprocess test would add a narrow Arq-version-regression guard. | +| 16 | Backlog | [chore_auto_followup_parent_advisory_lock](planned_features/02_mvp2/chore_auto_followup_parent_advisory_lock/idea.md) | Chore | The shipped `feat_auto_followup_studies` worker uses a two-layer idempotency scheme: | — | Idea — captured as a standalone file to resolve broken cross-references in `feat_auto_followup_studies` D-11 + plan F2 + `bug_auto_followup_completed_parent_stop_chain_race/idea.md`. The slug was coined 2026-05-24 in D-11 but only existed as descriptive prose across other documents until now. | +| 17 | Backlog | [bug_chat_long_conversation_truncation](planned_features/02_mvp2/bug_chat_long_conversation_truncation/idea.md) | Bug | [`backend/app/services/agent_chat.send_user_message`](../../backend/app/services/agent_chat.py) defensively caps the OpenAI history at the most recent `HISTORY_MAX_MESSAGES = 100` messages… | — | Held for MVP2 (decided 2026-05-13). Folder renamed with `_mvp2` suffix to make the deferral visible at-a-glance in `ls docs/00_overview/planned_features/`. Resume work when MVP2 starts — no technical dependency on MVP2 infra (audit_log is N/A; Langfuse is convenience only); the deferral is scope discipline + zero current impact (latent bug, no operator has hit the 100-message cap). | ## Dependency graph @@ -130,6 +130,8 @@ graph LR class chore_ubi_reader_search_after_pagination plan; feat_apply_path_normalizer_declaration["apply path normalizer declaration"] class feat_apply_path_normalizer_declaration plan; + feat_overnight_final_solution_phase3["overnight final solution phase3"] + class feat_overnight_final_solution_phase3 plan; feat_query_normalization_tuning["query normalization tuning"] class feat_query_normalization_tuning plan; feat_query_normalizer_typed_pipeline["query normalizer typed pipeline"] diff --git a/docs/00_overview/dashboard.html b/docs/00_overview/dashboard.html index dcb4914a..b6def12a 100644 --- a/docs/00_overview/dashboard.html +++ b/docs/00_overview/dashboard.html @@ -392,7 +392,7 @@

Releases

Three-Engine + Real Signals
-
19 / 28 scoped done · 25 remaining
+
19 / 29 scoped done · 26 remaining
In progress
diff --git a/docs/00_overview/mvp2_dashboard.html b/docs/00_overview/mvp2_dashboard.html index af3ca332..de386777 100644 --- a/docs/00_overview/mvp2_dashboard.html +++ b/docs/00_overview/mvp2_dashboard.html @@ -397,9 +397,9 @@

MVP2 Progress

Specced features done
-
19 / 28
-
68% specced · 50 filed under MVP2
-
+
19 / 29
+
66% specced · 50 filed under MVP2
+
Pending work
@@ -435,14 +435,14 @@

MVP2 Progress

Legacy "Path to MVP2"
-
25
+
26
scoped not-done + bugs + chore-ideas only (excludes feat/infra ideas)
Backlog ideas: - 4 idea-only feat/infra folders (not yet scoped into MVP2) + 3 idea-only feat/infra folders (not yet scoped into MVP2) In flight: @@ -463,20 +463,7 @@

Pipeline

-

Idea 18

- -
- -
- Feature - P2 - -
-
When `follow_suggestions` runs a 4-link chain, today's proposal-creation logic at [`backend/workers/orchestrator.py:693-740`](../backend/workers/orchestrator.py#L693-L740)…
- - -
- +

Idea 17

@@ -706,7 +693,7 @@

Spec 0

-

Plan 11

+

Plan 12

@@ -721,6 +708,19 @@

Plan 11

+
+ +
+ Feature + P2 + PR #440 merged 2026-06-04 +
+
Non-winning chain links' proposals transition `pending → superseded` when the chain terminates.
+ + +
+ +
@@ -1157,6 +1157,8 @@

Dependency graph (feat_ + infra_)

class chore_ubi_reader_search_after_pagination plan; feat_apply_path_normalizer_declaration["apply path normalizer declaration"] class feat_apply_path_normalizer_declaration plan; + feat_overnight_final_solution_phase3["overnight final solution phase3"] + class feat_overnight_final_solution_phase3 plan; feat_query_normalization_tuning["query normalization tuning"] class feat_query_normalization_tuning plan; feat_query_normalizer_typed_pipeline["query normalizer typed pipeline"] @@ -1220,6 +1222,8 @@

Dependency graph (feat_ + infra_)

class chore_ubi_reader_search_after_pagination plan; feat_apply_path_normalizer_declaration["apply path normalizer declaration"] class feat_apply_path_normalizer_declaration plan; + feat_overnight_final_solution_phase3["overnight final solution phase3"] + class feat_overnight_final_solution_phase3 plan; feat_query_normalization_tuning["query normalization tuning"] class feat_query_normalization_tuning plan; feat_query_normalizer_typed_pipeline["query normalizer typed pipeline"] diff --git a/docs/00_overview/planned_features/02_mvp2/feat_overnight_final_solution_phase3/feature_spec.md b/docs/00_overview/planned_features/02_mvp2/feat_overnight_final_solution_phase3/feature_spec.md new file mode 100644 index 00000000..772ff6e0 --- /dev/null +++ b/docs/00_overview/planned_features/02_mvp2/feat_overnight_final_solution_phase3/feature_spec.md @@ -0,0 +1,672 @@ +# Feature Specification — Overnight Final Solution Phase 3 (Proposal supersession on chain rollup) + +**Date:** 2026-06-05 +**Status:** Draft +**Owners:** RelyLoop maintainers +**Related docs:** +- [`idea.md`](idea.md) — preflight-cleaned 2026-06-05 +- [`feat_overnight_final_solution`](../../implemented_features/2026_06_04_feat_overnight_final_solution/feature_spec.md) (Phase 1 — PR #440, 2026-06-04) +- [`feat_overnight_final_solution_phase2`](../../implemented_features/2026_06_04_feat_overnight_final_solution_phase2/feature_spec.md) (Phase 2 — PR #442, 2026-06-04) +- [`feat_overnight_studies_summary_card`](../../implemented_features/2026_06_04_feat_overnight_studies_summary_card/feature_spec.md) (PR #444, 2026-06-04) +- [`feat_proposal_full_param_space_view`](../../implemented_features/2026_06_04_feat_proposal_full_param_space_view/feature_spec.md) (PR #446, 2026-06-04 — Cap-3 placement context) +- [`docs/01_architecture/api-conventions.md`](../../../01_architecture/api-conventions.md) +- [`docs/01_architecture/data-model.md`](../../../01_architecture/data-model.md) + +--- + +## 1) Purpose + +- **Problem:** Today's `_stop` orchestrator path creates **one `pending` proposal per completed chain link** ([`backend/workers/orchestrator.py:693-740`](../../../../backend/workers/orchestrator.py#L693-L740)). When `feat_overnight_autopilot` runs a 4-link chain, the operator's morning `/proposals` index shows up to 6 `pending` proposals (anchor + 5 descendants). Phase 1 surfaced a single "best" via `best_link_id` + `proposal_id_for_best_link` on `/chain`, but the index page still shows all 6 as ready-to-ship. Shipping any non-winner discards the chain's winning insight; the clutter dead-ends the operator. +- **Outcome:** Non-winning chain links' proposals transition `pending → superseded` when the chain terminates. `/proposals` defaults to hiding `superseded`; operators opt into seeing them. `pending` accurately means "ready to ship, no better alternative known." The full chain history is preserved (superseded ≠ deleted, no chain-traversal data loss). +- **Non-goal:** Auto-rejecting losers, auto-deleting losers, auto-opening a PR for the winner, or changing the winner-selection algorithm itself (that's `select_best_link` from Phase 1, unchanged here). + +## 2) Current state audit + +### Existing implementations + +- **`backend/app/db/models/proposal.py`** ([line 42](../../../../backend/app/db/models/proposal.py#L42)): the `proposals_status_check` CHECK constraint admits `status IN ('pending', 'pr_opened', 'pr_merged', 'rejected')` — Phase 3 extends this. +- **`backend/app/db/repo/proposal.py`** ([line 56](../../../../backend/app/db/repo/proposal.py#L56)): `ProposalStatusFilter = Literal["pending", "pr_opened", "pr_merged", "rejected"]` — the `?status=` query-param contract used by `list_proposals_paginated` ([line 169](../../../../backend/app/db/repo/proposal.py#L169), [line 221](../../../../backend/app/db/repo/proposal.py#L221)). +- **`backend/app/api/v1/schemas.py`** ([line 1379](../../../../backend/app/api/v1/schemas.py#L1379)): `ProposalStatusWire = Literal["pending", "pr_opened", "pr_merged", "rejected"]` — the response-payload type the OpenAPI schema exports. +- **`backend/app/db/repo/study.py`** ([line 341](../../../../backend/app/db/repo/study.py#L341)): `get_chain_for_study`'s proposal lookup filters `Proposal.status != "rejected"` to build `proposal_id_by_link_id`. This widens to `notin_(("rejected", "superseded"))`. +- **`backend/workers/orchestrator.py`** ([line 693](../../../../backend/workers/orchestrator.py#L693)): `_stop` opens the single transaction that calls `study_state.complete_study` then `repo.create_proposal(... status="pending" ...)`. Phase 3 appends a conditional rollup call inside that same transaction. +- **`backend/app/api/v1/proposals.py`** ([line 367](../../../../backend/app/api/v1/proposals.py#L367)): `list_proposals_endpoint` accepts `status_filter: ProposalStatusWire | None` — **a single optional value, not a list.** Phase 3 leaves this contract unchanged (D-15 revised) and adds a new sibling boolean param `include_superseded: bool = False`. The repo helper `list_proposals_paginated` ([line 192](../../../../backend/app/db/repo/proposal.py#L192)) implements the existing `status` filter as `Proposal.status == status`; Phase 3 adds an `include_superseded: bool = False` kwarg that, when `False`, appends `Proposal.status != 'superseded'` whenever `?status=` is not explicitly set. +- **`ui/src/lib/enums.ts`** ([lines 202-204](../../../../ui/src/lib/enums.ts#L202-L204)): `PROPOSAL_STATUS_VALUES` mirror, sourced from `ProposalStatusWire` per the form-dropdown discipline. +- **`ui/src/components/common/status-badge.tsx`** ([lines 23-28](../../../../ui/src/components/common/status-badge.tsx#L23-L28)): the `proposal:` block in the `StatusBadgeVariantMap` (`pending: 'secondary'`, `pr_opened: 'default'`, `pr_merged: 'success'`, `rejected: 'outline'`). +- **`backend/app/services/proposal_state.py`**: **does not exist**. RelyLoop's proposal-status transitions are gated via repo helpers using the conditional-UPDATE pattern (`reject_proposal` at [line 249](../../../../backend/app/db/repo/proposal.py#L249), `mark_proposal_pr_opened` at [line 272](../../../../backend/app/db/repo/proposal.py#L272), etc.). Phase 3 follows this precedent; it does NOT introduce a centralized state guard. +- **`backend/app/domain/study/chain_summary.py`** ([line 68](../../../../backend/app/domain/study/chain_summary.py#L68)): `CHAIN_STOP_REASONS` frozenset (`{depth_exhausted, no_lift, budget, parent_failed, cancelled, in_flight}`); [`derive_chain_stop_reason`](../../../../backend/app/domain/study/chain_summary.py#L107) + [`select_best_link`](../../../../backend/app/domain/study/chain_summary.py#L212) — Phase 3 reuses, does not rebuild. + +### Navigation and link impact + +| Source file | Current link target | New link target | +|---|---|---| +| `ui/src/app/proposals/page.tsx` | three-state filter chips (`all` / `study` / `manual`) + status-multi-filter | adds a "Show superseded" toggle that flows through the existing `?status=` repeated-query-param contract; default URL behavior unchanged for backward links | +| `ui/src/app/proposals/[id]/page.tsx` | proposal-detail page below `` + `` (added by PR #446) | adds a "Reinstate" button visible only when `proposal.status === 'superseded'`, placed alongside the existing "Open PR" / "Reject" affordances per D-11 | + +### Existing test impact + +| Test file | Pattern | Count | Required change | +|---|---|---|---| +| `backend/tests/unit/db/test_proposal_repo_conditional_update.py` | `WHERE status='pending'` precedent | 1 | Existing tests on `update_proposal_for_digest` continue passing unchanged; Phase 3 adds new tests against `bulk_mark_superseded` + `reinstate_from_superseded` in `test_proposal_supersession.py` (new file). | +| `backend/tests/integration/test_orchestrator_stop_supersedes_losers.py` (new) | `_stop` rollup | — | New: integration test that seeds a 3-link chain, completes the tail, asserts losers transition `pending → superseded` atomically with the winner's `pending` insert. | +| `backend/tests/integration/test_studies_chain_endpoint.py` | `proposal_id_by_link_id` resolution | existing | Phase 3 adds a case: when a link's only proposal is `superseded`, the link's `proposal_id_by_link_id` entry is **absent** (not surfaced as the "newest non-rejected"). | +| `backend/tests/contract/test_proposals_filter_contract.py` | `?status=` allowlist | existing | Extend `ProposalStatusWire` literal allowlist assertion to include `superseded`. | +| `ui/src/__tests__/components/proposals/proposals-list-page.test.tsx` | filter chip plumbing | existing | Add: default URL excludes `?status=superseded`; "Show superseded" toggle appends it; URL contract round-trips. | +| `ui/src/__tests__/components/common/form-select-discipline.test.tsx` | enums-import lint | existing | No code change — the lint guard automatically picks up the new `PROPOSAL_STATUS_VALUES` entry. | + +### Existing behaviors affected by scope change + +- **`/proposals` index default filter:** Current: returns all non-deleted proposals regardless of status. New: SQL-side default is unchanged (server returns all statuses); the frontend default URL drops `superseded` from its status set. Decision needed: No — the wire contract is backward-compatible (clients that don't filter still see everything; the front-end default just narrows what it asks for). +- **`get_chain_for_study` proposal resolution:** Current: returns the newest `status != 'rejected'` proposal per link. New: returns the newest `status NOT IN ('rejected', 'superseded')` proposal per link. Decision needed: No — losers' proposals are intentionally hidden from chain-traversal consumers (Phase 1's `best_link_id` + Phase 2's `` + Phase 3's `/proposals` filter all collaborate on the "one answer" promise). +- **`POST /api/v1/proposals/:id/reinstate`:** New endpoint. Current: no operator path to undo supersession. New: single-purpose endpoint flips `superseded → pending`; gated by `WHERE status='superseded'` (idempotent, race-safe). Decision needed: No (placement is D-11; verb is locked). + +--- + +## 3) Scope + +### In scope + +- **Cap 1 — Schema + wire-value mirrors.** Alembic migration `0023_proposals_superseded_status` extends `proposals_status_check` to admit `superseded`. ORM CHECK literal, `ProposalStatusFilter` repo Literal, `ProposalStatusWire` API Literal, frontend `PROPOSAL_STATUS_VALUES` mirror, and `StatusBadge`'s `proposal:` variant map all move in lockstep. `openapi.json` + `types.ts` regenerated via `scripts/regen-generated-artifacts.sh`. +- **Cap 2 — Service helper + repo helpers + chain-traversal co-requisite.** New `backend/app/services/chain_rollup.py` with `mark_non_winning_chain_proposals_superseded(db, *, study_id)`. New `backend/app/db/repo/proposal.py` helpers `bulk_mark_superseded(db, *, study_ids)` (conditional UPDATE `WHERE status='pending'` RETURNING ids) and `reinstate_from_superseded(db, *, proposal_id)` (conditional UPDATE `WHERE status='superseded'` raising `InvalidStateTransition` on miss). One-line widening at `backend/app/db/repo/study.py:341` from `Proposal.status != "rejected"` to `Proposal.status.notin_(("rejected", "superseded"))`. `_stop` ([`backend/workers/orchestrator.py:693`](../../../../backend/workers/orchestrator.py#L693)) appends a conditional call to the rollup helper inside its existing transaction. +- **Cap 3 — Frontend filter + reinstate UX + glossary.** `/proposals` index default URL excludes `?status=superseded`; a "Show superseded" toggle appends it. `StatusBadge` `proposal:` block adds `superseded: 'outline'` (visually distinct from `rejected` via copy + the existing card-frame, not the badge variant — D-12). `/proposals/[id]` adds a "Reinstate" button visible only when `proposal.status === 'superseded'`, placed alongside the existing "Open PR" / "Reject" affordances (D-11). New glossary entries `proposal.status.superseded` + `proposal.reinstate`. +- **Cap 4 — Pre-MVP3 telemetry.** Two new structlog INFO event types: `chain_proposals_superseded` (one per non-zero rollup) and `chain_proposal_reinstated` (one per operator reinstate). MVP3+ promotes both to `audit_log` rows. + +### Out of scope + +- Auto-rejecting non-winners (rejection stays an operator decision; supersession is the system's neutral signal). +- Auto-deleting non-winners (preserves audit trail). +- Auto-opening a PR for the best link (Phase 1's `best_link_id` + the operator's existing "Open PR" button continue to handle this). +- Changing `select_best_link`'s winner-selection algorithm. +- Surfacing the superseded marker on the chain panel (Cap 3 (ii) per idea — the chain panel renders only winning links' proposal CTAs; operators inspect losers via the "Show superseded" toggle on `/proposals`). +- Modifying `feat_overnight_studies_summary_card`'s `` — its `RecentChainSummary` response doesn't carry per-link proposal IDs, so it's unaffected by the chain-traversal filter widening. +- Modifying `feat_overnight_final_solution_phase2`'s `` — its best-config CTA renders from `chainSummary.best_link_id` / `proposal_id_for_best_link` directly; widening the chain-traversal filter automatically prevents superseded proposals from being chosen as a link's "newest non-rejected," but no Phase 2 code changes. + +### API convention check + +Verified against [`docs/01_architecture/api-conventions.md`](../../../01_architecture/api-conventions.md): + +- **Endpoint prefix:** `/api/v1/`. New endpoint lands at `/api/v1/proposals/{proposal_id}/reinstate`. ✓ +- **Router file:** `backend/app/api/v1/proposals.py` (new endpoint joins existing `reject_proposal_endpoint` + `open_pr_endpoint`). ✓ +- **HTTP method:** `POST` (single-purpose verb; sidesteps the broader debate about whether arbitrary `PATCH status=` should be allowed — see D-11). ✓ +- **Non-auth error envelope:** `{ "detail": { "error_code": "", "message": "", "retryable": } }` per the shared `error_envelope()` helper at [`backend/app/api/v1/proposals.py:79-89`](../../../../backend/app/api/v1/proposals.py#L79-L89). ✓ +- **Auth:** N/A — MVP1–MVP3 is single-tenant, no auth surface. + +### Phase boundaries + +**Single-phase delivery.** This spec covers the full Phase 3 scope as defined in the parent feature's §3 boundaries. No Phase 4 is deferred from this spec. + +--- + +## 4) Product principles and constraints + +- **The PR is the contract.** Supersession is internal bookkeeping; nothing in `proposals.status='superseded'` reaches GitHub or the operator's config repo. Only operator-initiated `open_pr` actions ship anywhere. +- **Audit trail preserved.** A superseded row stays in the DB indefinitely (no auto-delete, no hard-delete). The operator can always reinstate. +- **`rejected` is stronger than `superseded`.** Operator-initiated rejection beats system-initiated supersession — the rollup never touches a `rejected` row (Q3 locked). +- **`pr_opened` / `pr_merged` are stronger still.** Once a proposal is shipped, it's outside Phase 3's purview — the rollup never touches non-`pending` rows. +- **One-way flip on rollup; operator-initiated flip-back.** The system can supersede; only the operator can reinstate. Distinct verbs, distinct event types, distinct audit signals. + +### Anti-patterns + +- **Do not** introduce a `backend/app/services/proposal_state.py` central guard — the conditional-UPDATE-on-repo-helper precedent ([`reject_proposal`](../../../../backend/app/db/repo/proposal.py#L249), `mark_proposal_pr_opened`, etc.) is the codebase convention; a new central guard for two helpers would be net new abstraction surface for no benefit. +- **Do not** schedule the rollup as a separate Arq job after the digest — it's a pure-DB operation that belongs in the same transaction as the `_stop` `create_proposal` insert. Decoupling it adds queue surface, eventual-consistency windows, and operator-visible drift between `/chain` and `/proposals`. +- **Do not** implement a periodic reconciler — incentivizes silent state drift between operator views and the system's notion of "the answer." +- **Do not** auto-flip superseded → pending when `best_link_id` flips at operator-initiated re-run time — the Cap-2 helper is idempotent; re-running it with the new winner naturally reshuffles, and the `WHERE status='superseded'` guard prevents touching `pr_opened`/`pr_merged` rows (Q1 locked). +- **Do not** widen the chain-traversal filter at `study.py:341` without also widening the rollup helper — they must move in lockstep or the chain panel will still surface superseded proposals as the "newest non-rejected." This is the most common silent-regression risk. +- **Do not** mark frontend option values from memory. Every `