diff --git a/backend/app/api/v1/proposals.py b/backend/app/api/v1/proposals.py index c30194c6..99809709 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,53 @@ 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. + """ + # Single read-check-mutate via the repo helper (Gemini perf finding): + # catch its LookupError / InvalidStateTransition directly and reuse the + # returned row — no separate pre-read or post-commit refresh needed. + try: + proposal = await repo.reinstate_from_superseded(db, proposal_id=proposal_id) + except LookupError as exc: + raise _err(404, "PROPOSAL_NOT_FOUND", f"proposal {proposal_id} not found", False) from exc + 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", + ) + return await _assemble_proposal_detail(db, proposal) + + __all__ = ["router"] 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..14ff447d 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). @@ -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: @@ -632,3 +646,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/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..afda180f --- /dev/null +++ b/backend/app/services/chain_rollup.py @@ -0,0 +1,91 @@ +# 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.db.repo import ChainTraversalResult +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, + traversal: ChainTraversalResult | None = None, +) -> 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. + + ``traversal`` is an optional pre-fetched + :class:`~backend.app.db.repo.ChainTraversalResult`. When the caller + has already walked the chain (e.g. ``_stop`` reads it to capture the + anchor + winner for its log payload), passing it here avoids a + duplicate ``get_chain_for_study`` round-trip. When ``None`` the + function fetches it itself. + + 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. the chain is missing (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). + """ + if traversal is None: + 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/contract/test_openapi_surface.py b/backend/tests/contract/test_openapi_surface.py index 0addb780..4991f85c 100644 --- a/backend/tests/contract/test_openapi_surface.py +++ b/backend/tests/contract/test_openapi_surface.py @@ -105,6 +105,7 @@ ("get", "/api/v1/proposals/{proposal_id}", "200"), ("post", "/api/v1/proposals/{proposal_id}/reject", "200"), ("post", "/api/v1/proposals/{proposal_id}/open_pr", "202"), + ("post", "/api/v1/proposals/{proposal_id}/reinstate", "200"), # ----- /api/v1/conversations (feat_chat_agent) ----- ("post", "/api/v1/conversations", "201"), ("get", "/api/v1/conversations", "200"), 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 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/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..c87569d0 --- /dev/null +++ b/backend/tests/unit/services/test_chain_rollup_service.py @@ -0,0 +1,145 @@ +# 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, []) + + +async def test_prefetched_traversal_skips_get_chain_for_study( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Gemini perf finding: when the caller passes ``traversal=``, the service + must NOT re-issue ``get_chain_for_study``.""" + from backend.app.db import repo as repo_mod + + get_chain_mock = AsyncMock(return_value=None) + monkeypatch.setattr(repo_mod, "get_chain_for_study", get_chain_mock) + monkeypatch.setattr(repo_mod, "bulk_mark_superseded", AsyncMock(return_value=["loser_prop"])) + monkeypatch.setattr( + chain_rollup, "derive_chain_stop_reason", lambda links, anchor_trials: "no_lift" + ) + monkeypatch.setattr(chain_rollup, "select_best_link", lambda links: "winner") + prefetched: Any = _stub_traversal([_StubLink("loser"), _StubLink("winner")]) + db_stub: Any = object() + result = await chain_rollup.mark_non_winning_chain_proposals_superseded( + db_stub, study_id="any", traversal=prefetched + ) + assert result == (1, ["loser_prop"]) + get_chain_mock.assert_not_called() diff --git a/backend/workers/orchestrator.py b/backend/workers/orchestrator.py index 0fdff305..e352eb22 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,25 @@ 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. + # Pass the fetched traversal through to the rollup helper so + # it doesn't re-issue get_chain_for_study (Gemini perf + # finding — one fewer chain walk per chain-tail completion). + 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, traversal=traversal + ) + superseded_count = count + superseded_ids = ids await db.commit() except study_state.InvalidStateTransition: await db.rollback() @@ -749,6 +775,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) 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 `