diff --git a/src/api/consolidation.py b/src/api/consolidation.py index b358611..21fc6ed 100644 --- a/src/api/consolidation.py +++ b/src/api/consolidation.py @@ -14,7 +14,9 @@ resolve_consolidation_proposal, reverse_consolidation_execution, ) + from .proposals import ProposalActorRequest, ProposalReviewRequest except ImportError: + from api.proposals import ProposalActorRequest, ProposalReviewRequest from db.consolidation_queries import ( apply_consolidation_proposal, generate_consolidation_proposals, @@ -31,21 +33,15 @@ class GenerationRequest(BaseModel): minimum_score: float = Field(default=0.5, ge=0, le=1) -class ReviewRequest(BaseModel): - state: Literal["accepted", "rejected"] - reviewed_by: str = Field(min_length=1, max_length=200) - note: str | None = Field(default=None, max_length=4000) - - -class ActorRequest(BaseModel): - actor: str = Field(min_length=1, max_length=200) - note: str | None = Field(default=None, max_length=4000) - - @router.post("/proposals/generate", status_code=status.HTTP_201_CREATED) async def generate(request: GenerationRequest) -> dict: - proposals = generate_consolidation_proposals(limit=request.limit, minimum_score=request.minimum_score) - return {"created": len(proposals), "proposals": proposals} + try: + proposals = generate_consolidation_proposals( + limit=request.limit, minimum_score=request.minimum_score + ) + return {"created": len(proposals), "proposals": proposals} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.get("/proposals") @@ -53,41 +49,50 @@ async def list_proposals( state: Literal["pending", "accepted", "rejected", "superseded"] = "pending", limit: int = Query(default=100, ge=1, le=500), ) -> list[dict]: - return list_consolidation_proposals(state=state, limit=limit) + try: + return list_consolidation_proposals(state=state, limit=limit) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.post("/proposals/{proposal_id}/review") -async def review(proposal_id: UUID, request: ReviewRequest) -> dict: +async def review(proposal_id: UUID, request: ProposalReviewRequest) -> dict: try: proposal = resolve_consolidation_proposal( proposal_id, state=request.state, reviewed_by=request.reviewed_by, note=request.note ) except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + raise HTTPException(status_code=409, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc if proposal is None: raise HTTPException(status_code=404, detail="Pending consolidation proposal not found") return proposal @router.post("/proposals/{proposal_id}/apply") -async def apply(proposal_id: UUID, request: ActorRequest) -> dict: +async def apply(proposal_id: UUID, request: ProposalActorRequest) -> dict: try: execution = apply_consolidation_proposal(proposal_id, applied_by=request.actor) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc if execution is None: raise HTTPException(status_code=404, detail="Consolidation proposal not found") return execution @router.post("/executions/{execution_id}/reverse") -async def reverse(execution_id: UUID, request: ActorRequest) -> dict: +async def reverse(execution_id: UUID, request: ProposalActorRequest) -> dict: try: execution = reverse_consolidation_execution( execution_id, reversed_by=request.actor, note=request.note ) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc if execution is None: raise HTTPException(status_code=404, detail="Consolidation execution not found") return execution diff --git a/src/api/lifecycle.py b/src/api/lifecycle.py index 9dae203..18a16fa 100644 --- a/src/api/lifecycle.py +++ b/src/api/lifecycle.py @@ -17,7 +17,9 @@ reverse_lifecycle_execution, ) from ..lifecycle.execution import LifecycleExecutionError + from .proposals import ProposalActorRequest, ProposalReviewRequest except ImportError: # Support legacy execution with src/ directly on sys.path. + from api.proposals import ProposalActorRequest, ProposalReviewRequest from db.lifecycle_queries import ( apply_lifecycle_proposal, generate_lifecycle_proposals, @@ -35,21 +37,6 @@ class ProposalGenerationRequest(BaseModel): minimum_score: float = Field(default=0.25, ge=0, le=1) -class ProposalReviewRequest(BaseModel): - state: Literal["accepted", "rejected"] - reviewed_by: str = Field(min_length=1, max_length=200) - note: str | None = Field(default=None, max_length=4000) - - -class ProposalApplyRequest(BaseModel): - applied_by: str = Field(min_length=1, max_length=200) - - -class ProposalReverseRequest(BaseModel): - reversed_by: str = Field(min_length=1, max_length=200) - note: str | None = Field(default=None, max_length=4000) - - @router.post("/proposals/generate", status_code=status.HTTP_201_CREATED) async def generate_proposals(request: ProposalGenerationRequest) -> dict: try: @@ -77,7 +64,7 @@ async def review_proposal(proposal_id: UUID, request: ProposalReviewRequest) -> proposal_id, state=request.state, reviewed_by=request.reviewed_by, note=request.note ) except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + raise HTTPException(status_code=409, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc if proposal is None: @@ -86,9 +73,9 @@ async def review_proposal(proposal_id: UUID, request: ProposalReviewRequest) -> @router.post("/proposals/{proposal_id}/apply") -async def apply_proposal(proposal_id: UUID, request: ProposalApplyRequest) -> dict: +async def apply_proposal(proposal_id: UUID, request: ProposalActorRequest) -> dict: try: - execution = apply_lifecycle_proposal(proposal_id, applied_by=request.applied_by) + execution = apply_lifecycle_proposal(proposal_id, applied_by=request.actor) except LifecycleExecutionError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except Exception as exc: @@ -99,10 +86,10 @@ async def apply_proposal(proposal_id: UUID, request: ProposalApplyRequest) -> di @router.post("/executions/{execution_id}/reverse") -async def reverse_execution(execution_id: UUID, request: ProposalReverseRequest) -> dict: +async def reverse_execution(execution_id: UUID, request: ProposalActorRequest) -> dict: try: execution = reverse_lifecycle_execution( - execution_id, reversed_by=request.reversed_by, note=request.note + execution_id, reversed_by=request.actor, note=request.note ) except LifecycleExecutionError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc diff --git a/src/api/proposals.py b/src/api/proposals.py new file mode 100644 index 0000000..c5ae49b --- /dev/null +++ b/src/api/proposals.py @@ -0,0 +1,35 @@ +"""Shared REST contracts for human-reviewed proposal workflows.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +ProposalDecision = Literal["accepted", "rejected"] + + +class ProposalReviewRequest(BaseModel): + """Explicit human decision for a pending proposal.""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + state: ProposalDecision + reviewed_by: str = Field(min_length=1, max_length=200) + note: str | None = Field(default=None, max_length=4000) + + @model_validator(mode="after") + def require_rejection_note(self) -> "ProposalReviewRequest": + if self.state == "rejected" and not self.note: + raise ValueError("rejected proposals require a review note") + return self + + +class ProposalActorRequest(BaseModel): + """Actor attribution for applying or reversing an approved proposal.""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + actor: str = Field(min_length=1, max_length=200) + note: str | None = Field(default=None, max_length=4000) diff --git a/src/api/pruning.py b/src/api/pruning.py index 3de621a..23a85af 100644 --- a/src/api/pruning.py +++ b/src/api/pruning.py @@ -15,7 +15,9 @@ review_pruning_proposal, ) from ..pruning.execution import PruningConflict + from .proposals import ProposalActorRequest, ProposalReviewRequest except ImportError: + from api.proposals import ProposalActorRequest, ProposalReviewRequest from db.pruning_queries import ( apply_pruning_proposal, generate_pruning_proposals, @@ -32,21 +34,13 @@ class GenerateRequest(BaseModel): limit: int = Field(default=500, ge=1, le=2000) -class ReviewRequest(BaseModel): - state: Literal["accepted", "rejected"] - reviewed_by: str = Field(min_length=1, max_length=200) - note: str | None = Field(default=None, max_length=4000) - - -class ActorRequest(BaseModel): - actor: str = Field(min_length=1, max_length=200) - note: str | None = Field(default=None, max_length=4000) - - @router.post("/proposals/generate", status_code=status.HTTP_201_CREATED) async def generate(request: GenerateRequest) -> dict: - proposals = generate_pruning_proposals(limit=request.limit) - return {"created": len(proposals), "proposals": proposals} + try: + proposals = generate_pruning_proposals(limit=request.limit) + return {"created": len(proposals), "proposals": proposals} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.get("/proposals") @@ -54,38 +48,50 @@ async def list_proposals( state: Literal["pending", "accepted", "rejected", "applied", "reversed", "stale"] = "pending", limit: int = Query(default=100, ge=1, le=500), ) -> list[dict]: - return list_pruning_proposals(state=state, limit=limit) + try: + return list_pruning_proposals(state=state, limit=limit) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.post("/proposals/{proposal_id}/review") -async def review(proposal_id: UUID, request: ReviewRequest) -> dict: - proposal = review_pruning_proposal( - proposal_id, state=request.state, reviewed_by=request.reviewed_by, note=request.note - ) +async def review(proposal_id: UUID, request: ProposalReviewRequest) -> dict: + try: + proposal = review_pruning_proposal( + proposal_id, state=request.state, reviewed_by=request.reviewed_by, note=request.note + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc if proposal is None: raise HTTPException(status_code=404, detail="Pending pruning proposal not found") return proposal @router.post("/proposals/{proposal_id}/apply") -async def apply(proposal_id: UUID, request: ActorRequest) -> dict: +async def apply(proposal_id: UUID, request: ProposalActorRequest) -> dict: try: tombstone = apply_pruning_proposal(proposal_id, applied_by=request.actor) except (PruningConflict, ValueError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc if tombstone is None: raise HTTPException(status_code=404, detail="Pruning proposal not found") return tombstone @router.post("/tombstones/{tombstone_id}/restore") -async def restore(tombstone_id: UUID, request: ActorRequest) -> dict: +async def restore(tombstone_id: UUID, request: ProposalActorRequest) -> dict: try: tombstone = restore_tombstone( tombstone_id, reversed_by=request.actor, note=request.note ) except (PruningConflict, ValueError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc if tombstone is None: raise HTTPException(status_code=404, detail="Tombstone not found") return tombstone diff --git a/tests/test_proposal_api_contracts.py b/tests/test_proposal_api_contracts.py new file mode 100644 index 0000000..471876e --- /dev/null +++ b/tests/test_proposal_api_contracts.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from api.proposals import ProposalActorRequest, ProposalReviewRequest + + +def test_acceptance_allows_optional_note() -> None: + request = ProposalReviewRequest(state="accepted", reviewed_by="operator") + + assert request.state == "accepted" + assert request.reviewed_by == "operator" + assert request.note is None + + +def test_rejection_requires_explanation() -> None: + with pytest.raises(ValidationError, match="rejected proposals require a review note"): + ProposalReviewRequest(state="rejected", reviewed_by="operator") + + +def test_review_contract_strips_actor_and_note_whitespace() -> None: + request = ProposalReviewRequest( + state="rejected", + reviewed_by=" operator ", + note=" stale evidence ", + ) + + assert request.reviewed_by == "operator" + assert request.note == "stale evidence" + + +def test_review_contract_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError): + ProposalReviewRequest( + state="accepted", + reviewed_by="operator", + unexpected=True, + ) + + +def test_actor_contract_requires_explicit_attribution() -> None: + with pytest.raises(ValidationError): + ProposalActorRequest(actor=" ")